feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)

This commit is contained in:
James Rich 2026-03-21 18:19:13 -05:00 committed by GitHub
parent f04924ded5
commit d136b162a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
170 changed files with 2208 additions and 2432 deletions

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
/** No-op stubs for iOS target in core:ble. */
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// No-op for stubs
}
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
throw UnsupportedOperationException("iOS Peripheral not yet implemented")

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineDispatcher
/** Access to the IO dispatcher in a multiplatform-safe way. */
expect val ioDispatcher: CoroutineDispatcher

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** Multiplatform string formatting helper. */
expect fun formatString(pattern: String, vararg args: Any?): String

View file

@ -81,7 +81,7 @@ object HomoglyphCharacterStringTransformer {
*/
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
val stringBuilder = StringBuilder()
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping.getOrDefault(c, c))
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
return stringBuilder.toString()
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** Apple (iOS) implementation of string formatting. Stub implementation for compile-only validation. */
actual fun formatString(pattern: String, vararg args: Any?): String = throw UnsupportedOperationException(
"formatString is not supported on iOS at runtime; this target is intended for compile-only validation.",
)

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** No-op stubs for iOS target in core:common. */
actual object BuildUtils {
actual val isEmulator: Boolean = false
actual val sdkInt: Int = 0
}
actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List<String>) {
actual fun getQueryParameter(key: String): String? = null
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
actual override fun toString(): String = ""
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
}
}
actual fun CommonUri.toPlatformUri(): Any = Any()
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""
actual fun formatDateTime(timestampMillis: Long): String = ""
actual fun formatShortDate(timestampMillis: Long): String = ""
actual fun formatTime(timestampMillis: Long): String = ""
actual fun formatTimeWithSeconds(timestampMillis: Long): String = ""
actual fun formatDate(timestampMillis: Long): String = ""
actual fun formatDateTimeShort(timestampMillis: Long): String = ""
}
actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.METRIC
actual fun String?.isValidAddress(): Boolean = false
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize actual constructor()
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel actual constructor()
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>> actual constructor()
actual class CommonParcel {
actual fun readString(): String? = null
actual fun readInt(): Int = 0
actual fun readLong(): Long = 0L
actual fun readFloat(): Float = 0.0f
actual fun createByteArray(): ByteArray? = null
actual fun writeByteArray(b: ByteArray?) {}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** JVM/Android implementation of string formatting. */
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
@ -58,7 +58,7 @@ class CommandSenderImpl(
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()

View file

@ -17,13 +17,13 @@
package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
@ -64,7 +64,7 @@ class MeshActionHandlerImpl(
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View file

@ -18,12 +18,12 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
@ -56,7 +56,7 @@ class MeshConfigFlowManagerImpl(
private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L

View file

@ -17,7 +17,6 @@
package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
@ -41,7 +41,7 @@ class MeshConfigHandlerImpl(
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig())
override val localConfig = _localConfig.asStateFlow()

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
@ -89,7 +89,7 @@ class MeshConnectionManagerImpl(
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
@ -30,6 +29,7 @@ import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
@ -63,7 +63,7 @@ import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.unknown_username
@ -114,7 +114,7 @@ class MeshDataHandlerImpl(
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -433,9 +433,13 @@ class MeshDataHandlerImpl(
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.low_battery_title, nextNode.user.short_name),
title =
getStringSuspend(
Res.string.low_battery_title,
nextNode.user.short_name,
),
message =
getString(
getStringSuspend(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
@ -502,7 +506,9 @@ class MeshDataHandlerImpl(
val payload = packet.decoded?.payload ?: return
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle), Severity.Warn)
scope.launch {
serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
}
}
handleAckNak(
packet.decoded?.request_id ?: 0,
@ -659,25 +665,27 @@ class MeshDataHandlerImpl(
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getString(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
scope.launch {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
}
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private fun getSenderName(packet: DataPacket): String {
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
@ -701,7 +709,7 @@ class MeshDataHandlerImpl(
}
PortNum.WAYPOINT_APP.value -> {
val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),

View file

@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
@ -28,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
@ -55,7 +55,7 @@ class MeshMessageProcessorImpl(
private val router: Lazy<MeshRouter>,
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf<Int, String>()

View file

@ -19,13 +19,13 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
@ -39,7 +39,7 @@ class MqttManagerImpl(
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private var mqttMessageFlow: Job? = null
override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {

View file

@ -18,10 +18,10 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
@ -38,7 +38,7 @@ class NeighborInfoHandlerImpl(
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View file

@ -21,13 +21,13 @@ import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
@ -43,7 +43,7 @@ import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@ -62,7 +62,7 @@ class NodeManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
@ -196,13 +196,15 @@ class NodeManagerImpl(
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
}
if (newNode && !shouldPreserve) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
scope.handledLaunch {
notificationManager.dispatch(
Notification(
title = getStringSuspend(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
}
}
next
}

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
@ -30,6 +29,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@ -67,7 +67,7 @@ class PacketHandlerImpl(
}
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private var scope: CoroutineScope = CoroutineScope(ioDispatcher)
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()

View file

@ -18,11 +18,11 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
@ -44,7 +44,7 @@ class TracerouteHandlerImpl(
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View file

@ -24,7 +24,7 @@ import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
@ -122,14 +122,15 @@ abstract class MeshtasticDatabase : RoomDatabase() {
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.setQueryCoroutineContext(ioDispatcher)
}
}
@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo"))
@DeleteTable(tableName = "NodeInfo")
@DeleteTable(tableName = "MyNodeInfo")
class AutoMigration12to13 : AutoMigrationSpec
@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id"))
@DeleteColumn(tableName = "packet", columnName = "reply_id")
class AutoMigration29to30 : AutoMigrationSpec
@DeleteColumn(tableName = "packet", columnName = "retry_count")

View file

@ -17,11 +17,16 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
@ -55,11 +60,32 @@ actual fun deleteDatabase(dbName: String) {
/** Returns the system FileSystem for iOS. */
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
private object PreferencesSerializer : OkioSerializer<Preferences> {
override val defaultValue: Preferences
get() = emptyPreferences()
override suspend fun readFrom(source: BufferedSource): Preferences {
// iOS stub: return an empty Preferences instance instead of crashing.
return emptyPreferences()
}
override suspend fun writeTo(t: Preferences, sink: BufferedSink) {
// iOS stub: no-op to avoid crashing on write.
}
}
/** Creates an iOS DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = documentDirectory() + "/datastore"
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
return PreferenceDataStoreFactory.create(produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() })
return DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = PreferencesSerializer,
producePath = { (dir + "/$name.preferences_pb").toPath() },
),
)
}
@OptIn(ExperimentalForeignApi::class)

View file

@ -23,7 +23,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -33,6 +32,7 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.common.util.ioDispatcher
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
const val KEY_THEME = "theme"
@ -52,7 +52,7 @@ const val KEY_EXCLUDE_MQTT = "exclude-mqtt"
open class UiPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) :
UiPreferences {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
// Start this flow eagerly, so app intro doesn't flash (when disabled) on cold app start.
override val appIntroCompleted: StateFlow<Boolean> =

View file

@ -17,17 +17,17 @@
package org.meshtastic.core.datastore.di
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
@Named("DataStoreScope")
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}

View file

@ -29,5 +29,10 @@ kotlin {
androidResources.enable = false
}
sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) } }
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(libs.kotlinx.coroutines.core)
}
}
}

View file

@ -19,11 +19,12 @@ package org.meshtastic.core.di.di
import kotlinx.coroutines.Dispatchers
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.di.CoroutineDispatchers
@Module
class CoreDiModule {
@Single
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
CoroutineDispatchers(io = ioDispatcher, main = Dispatchers.Main, default = Dispatchers.Default)
}

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.domain"

View file

@ -19,12 +19,8 @@ package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import java.text.DateFormat
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
private val DAY_DURATION = 24.hours
@ -53,9 +49,3 @@ fun getShortDate(time: Long): String? {
* @param remainingMillis The remaining time in milliseconds
* @return Pair of (days, hours), where days is Int and hours is Double
*/
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
val duration = remainingMillis.milliseconds
if (duration <= Duration.ZERO) return 0 to 0.0
val totalHours = duration.toDouble(DurationUnit.HOURS)
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
}

View file

@ -98,7 +98,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
cleartextPSK
} else {
// Treat an index of 1 as the old channelDefaultKey and work up from there
val bytes = channelDefaultKey.clone()
val bytes = channelDefaultKey.copyOf()
bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte()
bytes.toByteString()
}

View file

@ -25,6 +25,7 @@ import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.CommonTypeParceler
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
@ -190,7 +191,7 @@ data class DataPacket(
// Public-key cryptography (PKC) channel index
const val PKC_CHANNEL_INDEX = 8
fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n)
fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n)
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()

View file

@ -20,6 +20,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
@ -143,20 +144,20 @@ data class Node(
val temp =
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
formatString("%.1f°C", temperature)
}
} else {
null
}
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soil_temperature)
formatString("%.1f°C", soil_temperature)
}
} else {
null
@ -164,12 +165,12 @@ data class Node(
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
formatString("%d%%", soil_moisture)
} else {
null
}
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(

View file

@ -16,6 +16,8 @@
*/
package org.meshtastic.core.model.util
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@ -46,3 +48,16 @@ fun formatUptime(seconds: Int): String {
.joinToString(" ")
}
}
/**
* Calculates the remaining mute time in days and hours.
*
* @param remainingMillis The remaining time in milliseconds
* @return Pair of (days, hours), where days is Int and hours is Double
*/
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
val duration = remainingMillis.milliseconds
if (duration <= kotlin.time.Duration.ZERO) return 0 to 0.0
val totalHours = duration.toDouble(kotlin.time.DurationUnit.HOURS)
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
}

View file

@ -19,6 +19,7 @@
package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.getSystemMeasurementSystem
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
@ -49,12 +50,15 @@ fun Int.metersIn(system: DisplayUnits): Float {
return this.metersIn(unit)
}
fun Float.toString(unit: DistanceUnit): String = if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) {
"%.0f %s"
} else {
"%.1f %s"
fun Float.toString(unit: DistanceUnit): String {
val pattern =
if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) {
"%.0f %s"
} else {
"%.1f %s"
}
return formatString(pattern, this, unit.symbol)
}
.format(this, unit.symbol)
fun Float.toString(system: DisplayUnits): String {
val unit =
@ -81,14 +85,14 @@ fun Int.toDistanceString(system: DisplayUnits): String {
@Suppress("MagicNumber")
fun Float.toSpeedString(system: DisplayUnits): String = if (system == DisplayUnits.METRIC) {
"%.0f km/h".format(this * 3.6)
formatString("%.0f km/h", this * 3.6)
} else {
"%.0f mph".format(this * 2.23694f)
formatString("%.0f mph", this * 2.23694f)
}
@Suppress("MagicNumber")
fun Float.toSmallDistanceString(system: DisplayUnits): String = if (system == DisplayUnits.IMPERIAL) {
"%.2f in".format(this / 25.4f)
formatString("%.2f in", this / 25.4f)
} else {
"%.0f mm".format(this)
formatString("%.0f mm", this)
}

View file

@ -62,7 +62,7 @@ private fun decodeSharedContactData(data: String): SharedContact {
sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string")
} catch (e: IllegalArgumentException) {
throw MalformedMeshtasticUrlException(
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
"Failed to Base64 decode SharedContact data ($data): ${e::class.simpleName}: ${e.message}",
)
}
@ -70,7 +70,7 @@ private fun decodeSharedContactData(data: String): SharedContact {
SharedContact.ADAPTER.decode(decodedBytes)
} catch (e: Exception) {
throw MalformedMeshtasticUrlException(
"Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}",
"Failed to proto decode SharedContact: ${e::class.simpleName}: ${e.message}",
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
/** No-op stubs for core:model on iOS. */
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
actual object SfppHasher {
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
}

View file

@ -22,8 +22,6 @@ plugins {
}
kotlin {
jvm()
android { namespace = "org.meshtastic.core.navigation" }
sourceSets {

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavKey
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
/**
* Shared polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used
* across Android and Desktop navigation graphs.
*/
val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
// Nodes
subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer())
subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer())
subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer())
subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer())
// Node detail sub-screens
subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer())
subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer())
subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer())
subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer())
subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer())
subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer())
subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer())
subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer())
subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer())
subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer())
subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer())
// Conversations
subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer())
subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer())
subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer())
subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer())
subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer())
// Map
subclass(MapRoutes.Map::class, MapRoutes.Map.serializer())
// Firmware
subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer())
subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer())
// Settings
subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer())
subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer())
subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer())
subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer())
subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer())
// Settings - Config routes
subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer())
subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer())
subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer())
subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer())
subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer())
subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer())
subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer())
subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer())
subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer())
subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer())
// Settings - Module routes
subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer())
subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer())
subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer())
subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer())
subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer())
subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer())
subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer())
subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer())
subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer())
subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer())
subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer())
subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer())
subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer())
subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer())
subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer())
subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer())
// Settings - Advanced routes
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
// Channels
subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer())
subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer())
// Connections
subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer())
subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer())
}
}
}

View file

@ -23,8 +23,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.network"

View file

@ -313,8 +313,8 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
long_name = "Sim " + Integer.toHexString(numIn),
short_name = getInitials("Sim " + Integer.toHexString(numIn)),
long_name = "Sim " + numIn.toString(16),
short_name = getInitials("Sim " + numIn.toString(16)),
hw_model = HardwareModel.ANDROID_SIM,
),
position =

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.nfc"

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
android {
namespace = "org.meshtastic.core.prefs"
androidResources.enable = false

View file

@ -24,9 +24,6 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
// Keep jvm() for desktop/server consumers
jvm()
// Override minSdk for ATAK compatibility (standard is 26)
android { minSdk = 21 }

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android { androidResources.enable = false }

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
/** No-op stub for Location on iOS. */
actual class Location

View file

@ -29,7 +29,10 @@ kotlin {
withHostTest { isIncludeAndroidResources = true }
}
sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }
sourceSets {
commonMain.dependencies { implementation(projects.core.common) }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
compose.resources {

View file

@ -25,12 +25,22 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
val pattern = composeGetString(stringResource)
if (formatArgs.isNotEmpty()) {
val resolvedArgs =
formatArgs
.map { arg ->
if (arg is StringResource) {
composeGetString(arg)
} else {
arg
}
}
.toTypedArray()
if (resolvedArgs.isNotEmpty()) {
@Suppress("SpreadOperator")
pattern.format(*formatArgs)
composeGetString(stringResource, *resolvedArgs)
} else {
pattern
composeGetString(stringResource)
}
}
@ -50,11 +60,10 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs:
}
.toTypedArray()
val pattern = composeGetString(stringResource)
return if (resolvedArgs.isNotEmpty()) {
@Suppress("SpreadOperator")
pattern.format(*resolvedArgs)
composeGetString(stringResource, *resolvedArgs)
} else {
pattern
composeGetString(stringResource)
}
}

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.service"
@ -45,6 +43,7 @@ kotlin {
implementation(projects.core.proto)
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
}

View file

@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BluetoothRepository
@ -100,7 +102,7 @@ class SharedRadioInterfaceService(
private var radioIf: RadioTransport? = null
private var isStarted = false
@Volatile private var listenersInitialized = false
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
private var heartbeatJob: kotlinx.coroutines.Job? = null
private var lastHeartbeatMillis = 0L
@ -108,42 +110,46 @@ class SharedRadioInterfaceService(
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
}
private val initLock = Mutex()
private fun initStateListeners() {
if (listenersInitialized) return
synchronized(this) {
if (listenersInitialized) return
listenersInitialized = true
if (listenersInitialized.value) return
processLifecycle.coroutineScope.launch {
initLock.withLock {
if (listenersInitialized.value) return@withLock
listenersInitialized.value = true
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
}
}
}
.launchIn(processLifecycle.coroutineScope)
.launchIn(processLifecycle.coroutineScope)
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
stopInterface()
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
stopInterface()
}
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
stopInterface()
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
stopInterface()
}
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}
}

View file

@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.library) }
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.testing"

View file

@ -23,8 +23,6 @@ plugins {
}
kotlin {
jvm()
android {
namespace = "org.meshtastic.core.ui"
androidResources.enable = false
@ -48,13 +46,15 @@ kotlin {
implementation(libs.compose.multiplatform.materialIconsExtended)
implementation(libs.compose.multiplatform.ui)
implementation(libs.compose.multiplatform.foundation)
implementation(libs.compose.multiplatform.ui.tooling)
api(libs.compose.multiplatform.ui.tooling.preview)
implementation(libs.kermit)
implementation(libs.koin.compose.viewmodel)
implementation(libs.qrcode.kotlin)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
androidMain.dependencies { implementation(libs.androidx.activity.compose) }
commonTest.dependencies {

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.jvm.JvmName
@Composable
fun <T : Enum<T>> DropDownPreference(

View file

@ -217,7 +217,7 @@ fun EditTextPreference(
isError = isError,
onValueChange = {
if (maxSize > 0) {
if (it.toByteArray().size <= maxSize) {
if (it.encodeToByteArray().size <= maxSize) {
onValueChanged(it)
}
} else {
@ -255,7 +255,7 @@ fun EditTextPreference(
if (maxSize > 0 && isFocused) {
Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.fillMaxWidth()) {
Text(
text = "${value.toByteArray().size}/$maxSize",
text = "${value.encodeToByteArray().size}/$maxSize",
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp),

View file

@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@ -153,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = "%s %.2fdB".format(stringResource(Res.string.snr), snr),
text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr),
color = color,
style = MaterialTheme.typography.labelSmall,
)
@ -171,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
}
Text(
modifier = modifier,
text = "%s %ddBm".format(stringResource(Res.string.rssi), rssi),
text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi),
color = color,
style = MaterialTheme.typography.labelSmall,
)

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.icon.BatteryEmpty
@ -60,7 +61,7 @@ fun MaterialBatteryInfo(
voltage: Float? = null,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val levelString = FORMAT.format(level)
val levelString = formatString(FORMAT, level)
Row(
modifier = modifier,
@ -130,7 +131,7 @@ fun MaterialBatteryInfo(
?.takeIf { it > 0 }
?.let {
Text(
text = "%.2fV".format(it),
text = formatString("%.2fV", it),
color = contentColor.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
)

View file

@ -16,6 +16,10 @@
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.MutableSharedFlow
/**
* Event emitted when a user re-presses a bottom navigation destination that should trigger a scroll-to-top behaviour on
* the corresponding screen.
@ -25,3 +29,5 @@ sealed class ScrollToTopEvent {
data object ConversationsTabPressed : ScrollToTopEvent()
}
@Composable fun rememberScrollToTopEvents(): MutableSharedFlow<ScrollToTopEvent> = remember { MutableSharedFlow() }

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
@ -63,7 +64,7 @@ fun SignalInfo(
tint = signalColor,
)
Text(
text = "%.1fdB · %ddBm · %s".format(node.snr, node.rssi, stringResource(quality.nameRes)),
text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,

View file

@ -43,7 +43,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -297,7 +297,7 @@ fun ScannedQrCodeDialog(
}
}
@PreviewScreenSizes
@PreviewLightDark
@Composable
private fun ScannedQrCodeDialogPreview() {
ScannedQrCodeDialog(

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L
internal actual fun <T : Enum<T>> enumEntriesOf(selectedItem: T): List<T> = emptyList()
internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
actual fun createClipEntry(text: String, label: String): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {}
@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> }
@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> }
@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> }
@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> }
@Composable actual fun SetScreenBrightness(brightness: Float) {}