mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
refactor: KMP Migration, Messaging Modularization, and Handshake Robustness (#4631)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -77,6 +77,15 @@ This file serves as a comprehensive guide for AI agents and developers working o
|
||||
- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
|
||||
- **Task Example:** `./gradlew assembleFdroidDebug`
|
||||
|
||||
### G. Kotlin Multiplatform (KMP) & Decoupling
|
||||
- **Goal:** We are actively moving logic and models from Android-specific modules to KMP modules (`core:common`, `core:model`, `core:proto`) to support future cross-platform expansion.
|
||||
- **Domain Models:** Always place domain models (Data Classes, Enums) in `commonMain` of the respective module.
|
||||
- **Parceling:**
|
||||
- Use the platform-agnostic `CommonParcelable` and `CommonParcelize` from `core:common`.
|
||||
- Avoid direct imports of `android.os.Parcelable` or `kotlinx.parcelize.Parcelize` in `commonMain`.
|
||||
- **Platform Abstractions:** Use `expect`/`actual` for platform-specific logic (e.g., `DateFormatter`, `RandomUtils`, `BuildUtils`).
|
||||
- **AIDL Compatibility:** AIDL parcelable declarations for models moved to `commonMain` should be relocated to `:core:api` to ensure proper export to consumer modules.
|
||||
|
||||
## 4. Quality Assurance
|
||||
|
||||
### A. Code Style (Spotless)
|
||||
|
||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
@@ -58,6 +59,7 @@ open class MeshUtilApplication :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextServices.app = this
|
||||
initializeMaps(this)
|
||||
|
||||
// Schedule periodic MeshLog cleanup
|
||||
|
||||
@@ -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,22 +14,25 @@
|
||||
* 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 com.geeksville.mesh.navigation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.navigation.navigation
|
||||
import androidx.navigation.toRoute
|
||||
import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
|
||||
import com.geeksville.mesh.ui.sharing.ShareScreen
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.feature.messaging.QuickChatScreen
|
||||
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
|
||||
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
|
||||
@@ -37,7 +40,19 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
||||
composable<ContactsRoutes.Contacts>(
|
||||
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
|
||||
) {
|
||||
AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
|
||||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onHandleScannedUri = uiViewModel::handleScannedUri,
|
||||
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
||||
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
||||
)
|
||||
}
|
||||
composable<ContactsRoutes.Messages>(
|
||||
deepLinks =
|
||||
@@ -49,9 +64,18 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
|
||||
val uiViewModel: UIViewModel = hiltViewModel()
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
|
||||
AdaptiveContactsScreen(
|
||||
navController = navController,
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onHandleScannedUri = uiViewModel::handleScannedUri,
|
||||
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
|
||||
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
|
||||
initialContactKey = args.contactKey,
|
||||
initialMessage = args.message,
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ constructor(
|
||||
get() = newNodes.size
|
||||
|
||||
private var rawMyNodeInfo: MyNodeInfo? = null
|
||||
private var lastMetadata: DeviceMetadata? = null
|
||||
private var newMyNodeInfo: MyNodeEntity? = null
|
||||
private var myNodeInfo: MyNodeEntity? = null
|
||||
|
||||
@@ -79,12 +80,20 @@ constructor(
|
||||
}
|
||||
|
||||
private fun handleConfigOnlyComplete() {
|
||||
Logger.i { "Config-only complete" }
|
||||
Logger.i { "Config-only complete (Stage 1)" }
|
||||
if (newMyNodeInfo == null) {
|
||||
Logger.e { "Did not receive a valid config - newMyNodeInfo is null" }
|
||||
Logger.w {
|
||||
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
|
||||
}
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
}
|
||||
|
||||
val finalizedInfo = newMyNodeInfo
|
||||
if (finalizedInfo == null) {
|
||||
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
|
||||
} else {
|
||||
myNodeInfo = newMyNodeInfo
|
||||
Logger.i { "myNodeInfo committed successfully" }
|
||||
myNodeInfo = finalizedInfo
|
||||
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||
connectionManager.onRadioConfigLoaded()
|
||||
}
|
||||
|
||||
@@ -92,6 +101,7 @@ constructor(
|
||||
delay(wantConfigDelay)
|
||||
sendHeartbeat()
|
||||
delay(wantConfigDelay)
|
||||
Logger.i { "Requesting NodeInfo (Stage 2)" }
|
||||
connectionManager.startNodeInfoOnly()
|
||||
}
|
||||
}
|
||||
@@ -106,7 +116,7 @@ constructor(
|
||||
}
|
||||
|
||||
private fun handleNodeInfoComplete() {
|
||||
Logger.i { "NodeInfo complete" }
|
||||
Logger.i { "NodeInfo complete (Stage 2)" }
|
||||
val entities =
|
||||
newNodes.map { info ->
|
||||
nodeManager.installNodeInfo(info, withBroadcast = false)
|
||||
@@ -134,8 +144,8 @@ constructor(
|
||||
fun handleMyInfo(myInfo: MyNodeInfo) {
|
||||
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
|
||||
rawMyNodeInfo = myInfo
|
||||
nodeManager.myNodeNum = myInfo.my_node_num ?: 0
|
||||
regenMyNodeInfo()
|
||||
nodeManager.myNodeNum = myInfo.my_node_num
|
||||
regenMyNodeInfo(lastMetadata)
|
||||
|
||||
scope.handledLaunch {
|
||||
radioConfigRepository.clearChannelSet()
|
||||
@@ -145,7 +155,8 @@ constructor(
|
||||
}
|
||||
|
||||
fun handleLocalMetadata(metadata: DeviceMetadata) {
|
||||
Logger.i { "Local Metadata received" }
|
||||
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
|
||||
lastMetadata = metadata
|
||||
regenMyNodeInfo(metadata)
|
||||
}
|
||||
|
||||
@@ -153,36 +164,43 @@ constructor(
|
||||
newNodes.add(info)
|
||||
}
|
||||
|
||||
private fun regenMyNodeInfo(metadata: DeviceMetadata? = DeviceMetadata()) {
|
||||
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
|
||||
val myInfo = rawMyNodeInfo
|
||||
if (myInfo != null) {
|
||||
val mi =
|
||||
with(myInfo) {
|
||||
MyNodeEntity(
|
||||
myNodeNum = my_node_num ?: 0,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version,
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version ?: 0,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
deviceId = device_id?.utf8() ?: "",
|
||||
pioEnv = if (myInfo.pio_env.isNullOrEmpty()) null else myInfo.pio_env,
|
||||
)
|
||||
try {
|
||||
val mi =
|
||||
with(myInfo) {
|
||||
MyNodeEntity(
|
||||
myNodeNum = my_node_num ?: 0,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
deviceId = device_id.utf8(),
|
||||
pioEnv = myInfo.pio_env.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
if (metadata != null && metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
|
||||
}
|
||||
if (metadata != null && metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
|
||||
newMyNodeInfo = mi
|
||||
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Failed to regenMyNodeInfo" }
|
||||
}
|
||||
newMyNodeInfo = mi
|
||||
} else {
|
||||
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,15 @@ import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected_count
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.meshtastic_app_name
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
@@ -77,12 +79,16 @@ constructor(
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var sleepTimeout: Job? = null
|
||||
private var locationRequestsJob: Job? = null
|
||||
private var handshakeTimeout: Job? = null
|
||||
private var connectTimeMsec = 0L
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
|
||||
|
||||
// Ensure notification title and content stay in sync with state changes
|
||||
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
|
||||
|
||||
nodeRepository.myNodeInfo
|
||||
.onEach { myNodeEntity ->
|
||||
locationRequestsJob?.cancel()
|
||||
@@ -122,11 +128,21 @@ constructor(
|
||||
}
|
||||
|
||||
private fun onConnectionChanged(c: ConnectionState) {
|
||||
if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
|
||||
Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" }
|
||||
val current = connectionStateHolder.connectionState.value
|
||||
if (current == c) return
|
||||
|
||||
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
|
||||
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
|
||||
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i { "onConnectionChanged: $current -> $c" }
|
||||
|
||||
sleepTimeout?.cancel()
|
||||
sleepTimeout = null
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
when (c) {
|
||||
is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
|
||||
@@ -134,19 +150,33 @@ constructor(
|
||||
is ConnectionState.DeviceSleep -> handleDeviceSleep()
|
||||
is ConnectionState.Disconnected -> handleDisconnected()
|
||||
}
|
||||
updateStatusNotification()
|
||||
}
|
||||
|
||||
private fun handleConnected() {
|
||||
// The service state remains 'Connecting' until config is fully loaded
|
||||
if (connectionStateHolder.connectionState.value == ConnectionState.Disconnected) {
|
||||
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
|
||||
connectionStateHolder.setState(ConnectionState.Connecting)
|
||||
}
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
Logger.d { "Starting connect" }
|
||||
Logger.i { "Starting mesh handshake (Stage 1)" }
|
||||
connectTimeMsec = nowMillis
|
||||
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
|
||||
startConfigOnly()
|
||||
|
||||
// Guard against handshake stalls
|
||||
handshakeTimeout =
|
||||
scope.handledLaunch {
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.w { "Handshake stall detected! Retrying Stage 1." }
|
||||
startConfigOnly()
|
||||
// Recursive timeout for one more try
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.e { "Handshake still stalled after retry. Resetting connection." }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeviceSleep() {
|
||||
@@ -215,6 +245,9 @@ constructor(
|
||||
}
|
||||
|
||||
fun onNodeDbReady() {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
// Start MQTT if enabled
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
@@ -236,7 +269,9 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusNotification()
|
||||
// Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
}
|
||||
|
||||
private fun reportConnection() {
|
||||
@@ -258,8 +293,7 @@ constructor(
|
||||
val summary =
|
||||
when (connectionStateHolder.connectionState.value) {
|
||||
is ConnectionState.Connected ->
|
||||
getString(Res.string.connected_count)
|
||||
.format(nodeManager.nodeDBbyNodeNum.values.count { it.isOnline })
|
||||
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
|
||||
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
|
||||
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
|
||||
is ConnectionState.Connecting -> getString(Res.string.connecting)
|
||||
@@ -271,6 +305,7 @@ constructor(
|
||||
private const val CONFIG_ONLY_NONCE = 69420
|
||||
private const val NODE_INFO_NONCE = 69421
|
||||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||
private val HANDSHAKE_TIMEOUT = 10.seconds
|
||||
|
||||
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
|
||||
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
|
||||
|
||||
@@ -16,40 +16,17 @@
|
||||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.meshtastic.core.model.util.MeshDataMapper as CommonMeshDataMapper
|
||||
|
||||
@Singleton
|
||||
class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) {
|
||||
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
|
||||
DataPacket.ID_BROADCAST
|
||||
} else {
|
||||
nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
|
||||
}
|
||||
private val commonMapper = CommonMeshDataMapper(nodeManager)
|
||||
|
||||
fun toDataPacket(packet: MeshPacket): DataPacket? {
|
||||
val decoded = packet.decoded ?: return null
|
||||
return DataPacket(
|
||||
from = toNodeID(packet.from),
|
||||
to = toNodeID(packet.to),
|
||||
time = packet.rx_time * 1000L,
|
||||
id = packet.id,
|
||||
dataType = decoded.portnum.value,
|
||||
bytes = decoded.payload.toByteArray().toByteString(),
|
||||
hopLimit = packet.hop_limit,
|
||||
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
|
||||
wantAck = packet.want_ack == true,
|
||||
hopStart = packet.hop_start,
|
||||
snr = packet.rx_snr,
|
||||
rssi = packet.rx_rssi,
|
||||
replyId = decoded.reply_id,
|
||||
relayNode = packet.relay_node,
|
||||
viaMqtt = packet.via_mqtt == true,
|
||||
emoji = decoded.emoji,
|
||||
transportMechanism = packet.transport_mechanism.value,
|
||||
)
|
||||
}
|
||||
fun toNodeID(n: Int): String = nodeManager.toNodeID(n)
|
||||
|
||||
fun toDataPacket(packet: MeshPacket): DataPacket? = commonMapper.toDataPacket(packet)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,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.NodeIdLookup
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
@@ -54,7 +55,7 @@ constructor(
|
||||
private val nodeRepository: NodeRepository?,
|
||||
private val serviceBroadcasts: MeshServiceBroadcasts?,
|
||||
private val serviceNotifications: MeshServiceNotifications?,
|
||||
) {
|
||||
) : NodeIdLookup {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val nodeDBbyNodeNum = ConcurrentHashMap<Int, NodeEntity>()
|
||||
@@ -260,9 +261,9 @@ constructor(
|
||||
return hasExistingUser && isDefaultName && isDefaultHwModel
|
||||
}
|
||||
|
||||
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
|
||||
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
|
||||
DataPacket.ID_BROADCAST
|
||||
} else {
|
||||
nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
|
||||
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
@@ -56,6 +57,16 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.local_stats_bad
|
||||
import org.meshtastic.core.resources.local_stats_battery
|
||||
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
|
||||
import org.meshtastic.core.resources.local_stats_dropped
|
||||
import org.meshtastic.core.resources.local_stats_nodes
|
||||
import org.meshtastic.core.resources.local_stats_noise
|
||||
import org.meshtastic.core.resources.local_stats_relays
|
||||
import org.meshtastic.core.resources.local_stats_traffic
|
||||
import org.meshtastic.core.resources.local_stats_uptime
|
||||
import org.meshtastic.core.resources.local_stats_utilization
|
||||
import org.meshtastic.core.resources.low_battery_message
|
||||
import org.meshtastic.core.resources.low_battery_title
|
||||
import org.meshtastic.core.resources.mark_as_read
|
||||
@@ -112,6 +123,7 @@ constructor(
|
||||
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
|
||||
private const val BULLET = "• "
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,35 +282,59 @@ constructor(
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
var cachedTelemetry: Telemetry? = null
|
||||
var cachedLocalStats: LocalStats? = null
|
||||
var nextStatsUpdateMillis: Long = 0
|
||||
var cachedMessage: String? = null
|
||||
private var cachedDeviceMetrics: DeviceMetrics? = null
|
||||
private var cachedLocalStats: LocalStats? = null
|
||||
private var nextStatsUpdateMillis: Long = 0
|
||||
private var cachedMessage: String? = null
|
||||
|
||||
// region Public Notification Methods
|
||||
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
|
||||
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
|
||||
val hasLocalStats = telemetry?.local_stats != null
|
||||
val hasDeviceMetrics = telemetry?.device_metrics != null
|
||||
// Update caches if telemetry is provided
|
||||
telemetry?.let { t ->
|
||||
t.local_stats?.let { stats ->
|
||||
cachedLocalStats = stats
|
||||
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
|
||||
}
|
||||
t.device_metrics?.let { metrics -> cachedDeviceMetrics = metrics }
|
||||
}
|
||||
|
||||
// Seeding from database if caches are still null (e.g. on restart or reconnection)
|
||||
if (cachedLocalStats == null || cachedDeviceMetrics == null) {
|
||||
val repo = nodeRepository.get()
|
||||
val myNodeNum = repo.myNodeInfo.value?.myNodeNum
|
||||
if (myNodeNum != null) {
|
||||
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
|
||||
// and we only do this once if the cache is empty.
|
||||
val nodes = runBlocking { repo.getNodeDBbyNum().first() }
|
||||
nodes[myNodeNum]?.let { entity ->
|
||||
if (cachedDeviceMetrics == null) {
|
||||
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
|
||||
}
|
||||
if (cachedLocalStats == null) {
|
||||
cachedLocalStats = entity.deviceTelemetry.local_stats
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val stats = cachedLocalStats
|
||||
val metrics = cachedDeviceMetrics
|
||||
|
||||
val message =
|
||||
when {
|
||||
hasLocalStats -> {
|
||||
val localStatsMessage = telemetry?.local_stats?.formatToString()
|
||||
cachedTelemetry = telemetry
|
||||
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
|
||||
localStatsMessage
|
||||
}
|
||||
cachedTelemetry == null && hasDeviceMetrics -> {
|
||||
val deviceMetricsMessage = telemetry?.device_metrics?.formatToString()
|
||||
if (cachedLocalStats == null) {
|
||||
cachedTelemetry = telemetry
|
||||
}
|
||||
nextStatsUpdateMillis = nowMillis
|
||||
deviceMetricsMessage
|
||||
}
|
||||
stats != null -> stats.formatToString(metrics?.battery_level)
|
||||
metrics != null -> metrics.formatToString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats)
|
||||
// Only update cachedMessage if we have something new, otherwise keep what we have.
|
||||
// Fallback to "No Stats Available" only if we truly have nothing.
|
||||
if (message != null) {
|
||||
cachedMessage = message
|
||||
} else if (cachedMessage == null) {
|
||||
cachedMessage = getString(Res.string.no_local_stats)
|
||||
}
|
||||
|
||||
val notification =
|
||||
createServiceStateNotification(
|
||||
@@ -471,7 +507,8 @@ constructor(
|
||||
.setShowWhen(true)
|
||||
|
||||
message?.let {
|
||||
builder.setContentText(it)
|
||||
// First line of message is used for collapsed view, ensure it doesn't have a bullet
|
||||
builder.setContentText(it.substringBefore("\n").removePrefix(BULLET))
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
|
||||
}
|
||||
|
||||
@@ -633,7 +670,7 @@ constructor(
|
||||
private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
|
||||
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
|
||||
val title = getString(Res.string.low_battery_title).format(node.shortName)
|
||||
val batteryLevel = node.deviceTelemetry?.device_metrics?.battery_level ?: 0
|
||||
val batteryLevel = node.deviceMetrics?.battery_level ?: 0
|
||||
val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
|
||||
|
||||
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
|
||||
@@ -811,23 +848,48 @@ constructor(
|
||||
|
||||
return IconCompat.createWithBitmap(bitmap)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Extension Functions (Localized)
|
||||
|
||||
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
|
||||
val parts = mutableListOf<String>()
|
||||
batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
|
||||
parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes))
|
||||
parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds)))
|
||||
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization, air_util_tx))
|
||||
|
||||
// Traffic Stats
|
||||
if (num_packets_tx > 0 || num_packets_rx > 0) {
|
||||
parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe))
|
||||
}
|
||||
if (num_tx_relay > 0) {
|
||||
parts.add(BULLET + getString(Res.string.local_stats_relays, num_tx_relay, num_tx_relay_canceled))
|
||||
}
|
||||
|
||||
// Diagnostic Fields
|
||||
val diagnosticParts = mutableListOf<String>()
|
||||
if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise, noise_floor))
|
||||
if (num_packets_rx_bad > 0) diagnosticParts.add(getString(Res.string.local_stats_bad, num_packets_rx_bad))
|
||||
if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped, num_tx_dropped))
|
||||
|
||||
if (diagnosticParts.isNotEmpty()) {
|
||||
parts.add(
|
||||
BULLET + getString(Res.string.local_stats_diagnostics_prefix, diagnosticParts.joinToString(" | ")),
|
||||
)
|
||||
}
|
||||
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun DeviceMetrics.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
|
||||
uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) }
|
||||
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f))
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
// Extension function to format LocalStats into a readable string.
|
||||
private fun LocalStats.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("Uptime: ${formatUptime(uptime_seconds)}")
|
||||
parts.add("ChUtil: %.2f%%".format(channel_utilization))
|
||||
parts.add("AirUtilTX: %.2f%%".format(air_util_tx))
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun DeviceMetrics.formatToString(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
battery_level?.let { parts.add("Battery Level: $it") }
|
||||
uptime_seconds?.let { parts.add("Uptime: ${formatUptime(it)}") }
|
||||
channel_utilization?.let { parts.add("ChUtil: %.2f%%".format(it)) }
|
||||
air_util_tx?.let { parts.add("AirUtilTX: %.2f%%".format(it)) }
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
@@ -507,42 +507,47 @@ private fun VersionChecks(viewModel: UIViewModel) {
|
||||
},
|
||||
)
|
||||
} else {
|
||||
myFirmwareVersion?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
|
||||
"min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
myFirmwareVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { fwVersion ->
|
||||
val curVer = DeviceVersion(fwVersion)
|
||||
Logger.i {
|
||||
"[FW_CHECK] Firmware version comparison - " +
|
||||
"device: $curVer (raw: $fwVersion), " +
|
||||
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
|
||||
"min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
|
||||
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
|
||||
if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware too old - " +
|
||||
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
val title = getString(Res.string.firmware_too_old)
|
||||
val message = getString(Res.string.firmware_old)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
html = message,
|
||||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
Logger.w {
|
||||
"[FW_CHECK] Firmware should update - " +
|
||||
"device: $curVer < min: ${MeshService.minDeviceVersion}"
|
||||
}
|
||||
val title = getString(Res.string.should_update_firmware)
|
||||
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, onConfirm = {})
|
||||
} else {
|
||||
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
|
||||
}
|
||||
} ?: run { Logger.w { "[FW_CHECK] Firmware version is null despite myNodeInfo being present" } }
|
||||
?: run {
|
||||
Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
|
||||
}
|
||||
}
|
||||
} ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
|
||||
} else {
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
*/
|
||||
package com.geeksville.mesh.ui.connections
|
||||
|
||||
import android.net.InetAddresses
|
||||
import android.os.Build
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -87,15 +84,6 @@ import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.proto.Config
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
} else {
|
||||
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
|
||||
* displays connection status.
|
||||
@@ -180,8 +168,9 @@ fun ConnectionsScreen(
|
||||
val uiState =
|
||||
when {
|
||||
connectionState.isConnected() && ourNode != null -> 2
|
||||
connectionState == ConnectionState.Connecting ||
|
||||
(connectionState == ConnectionState.Disconnected && selectedDevice != "n") -> 1
|
||||
connectionState.isConnected() ||
|
||||
connectionState == ConnectionState.Connecting ||
|
||||
selectedDevice != NO_DEVICE_SELECTED -> 1
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@@ -118,14 +118,17 @@ fun CurrentlyConnectedInfo(
|
||||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(text = node.user.long_name ?: "", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
node.metadata?.firmware_version?.let { firmwareVersion ->
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_version, firmwareVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
node.metadata
|
||||
?.firmware_version
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { firmwareVersion ->
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_version, firmwareVersion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.DeviceListEntry
|
||||
import com.geeksville.mesh.repository.network.NetworkRepository
|
||||
import com.geeksville.mesh.ui.connections.ScannerViewModel
|
||||
import com.geeksville.mesh.ui.connections.isValidAddress
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.isValidAddress
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_network_device
|
||||
import org.meshtastic.core.resources.address
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -68,6 +69,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.util.getChannelUrl
|
||||
import org.meshtastic.core.model.util.qrCode
|
||||
@@ -299,10 +301,10 @@ fun ChannelScreen(
|
||||
|
||||
@Composable
|
||||
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
|
||||
val url = channelSet.getChannelUrl(shouldAddChannel)
|
||||
val commonUri = channelSet.getChannelUrl(shouldAddChannel)
|
||||
QrDialog(
|
||||
title = stringResource(Res.string.share_channels_qr),
|
||||
uri = url,
|
||||
uri = commonUri.toPlatformUri() as Uri,
|
||||
qrCode = channelSet.qrCode(shouldAddChannel),
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<path
|
||||
android:pathData="m17.5564,11.8482 l-5.208,7.6376 -1.5217,-1.0377 5.9674,-8.7512c0.1714,-0.2513 0.4558,-0.4019 0.76,-0.4022 0.3042,-0.0003 0.5889,0.1497 0.7608,0.4008l5.9811,8.7374 -1.5199,1.0404z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillColor="#ff000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m5.854,19.4956 l6.3707,-9.3423 -1.5749,-1.0739 -6.3707,9.3423z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="@android:color/black"
|
||||
android:fillColor="#ff000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -24,7 +24,6 @@ import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
@@ -42,6 +41,7 @@ class MeshDataMapperTest {
|
||||
|
||||
@Test
|
||||
fun `toNodeID resolves broadcast correctly`() {
|
||||
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
|
||||
assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST))
|
||||
}
|
||||
|
||||
@@ -49,9 +49,7 @@ class MeshDataMapperTest {
|
||||
fun `toNodeID resolves known node correctly`() {
|
||||
val nodeNum = 1234
|
||||
val nodeId = "!1234abcd"
|
||||
val nodeEntity = mockk<NodeEntity>()
|
||||
every { nodeEntity.user.id } returns nodeId
|
||||
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns nodeEntity
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
|
||||
assertEquals(nodeId, mapper.toNodeID(nodeNum))
|
||||
}
|
||||
@@ -59,9 +57,10 @@ class MeshDataMapperTest {
|
||||
@Test
|
||||
fun `toNodeID resolves unknown node to default ID`() {
|
||||
val nodeNum = 1234
|
||||
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns null
|
||||
val nodeId = DataPacket.nodeNumToDefaultId(nodeNum)
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
|
||||
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), mapper.toNodeID(nodeNum))
|
||||
assertEquals(nodeId, mapper.toNodeID(nodeNum))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -74,9 +73,8 @@ class MeshDataMapperTest {
|
||||
fun `toDataPacket maps basic fields correctly`() {
|
||||
val nodeNum = 1234
|
||||
val nodeId = "!1234abcd"
|
||||
val nodeEntity = mockk<NodeEntity>()
|
||||
every { nodeEntity.user.id } returns nodeId
|
||||
every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
|
||||
every { nodeManager.toNodeID(nodeNum) } returns nodeId
|
||||
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
|
||||
|
||||
val proto =
|
||||
MeshPacket(
|
||||
@@ -113,7 +111,7 @@ class MeshDataMapperTest {
|
||||
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
|
||||
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
|
||||
|
||||
every { nodeManager.nodeDBbyNodeNum[any()] } returns null
|
||||
every { nodeManager.toNodeID(any()) } returns "any"
|
||||
|
||||
val result = mapper.toDataPacket(proto)
|
||||
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
|
||||
|
||||
@@ -15,10 +15,10 @@ dependencies {
|
||||
// The core AIDL interface and Intent constants
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
|
||||
|
||||
// Data models (DataPacket, MeshUser, NodeInfo, etc.)
|
||||
// Data models (DataPacket, MeshUser, NodeInfo, etc.) - Kotlin Multiplatform
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
|
||||
|
||||
// Protobuf definitions (PortNum, Telemetry, etc.)
|
||||
// Protobuf definitions (PortNum, Telemetry, etc.) - Kotlin Multiplatform
|
||||
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.x.x")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins { alias(libs.plugins.meshtastic.kmp.library) }
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.library)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.meshtastic.core.common
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
@@ -25,6 +26,11 @@ import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/** Global accessor for Android Application. Must be initialized at app startup. */
|
||||
object ContextServices {
|
||||
lateinit var app: Application
|
||||
}
|
||||
|
||||
/** Checks if the device has a GPS receiver. */
|
||||
fun Context.hasGps(): Boolean {
|
||||
val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager
|
||||
|
||||
@@ -19,9 +19,9 @@ package org.meshtastic.core.common.util
|
||||
import android.os.Build
|
||||
|
||||
/** Utility for checking build properties, such as emulator detection. */
|
||||
object BuildUtils {
|
||||
actual object BuildUtils {
|
||||
/** Whether the app is currently running on an emulator. */
|
||||
val isEmulator: Boolean
|
||||
actual val isEmulator: Boolean
|
||||
get() =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.startsWith("unknown") ||
|
||||
@@ -32,4 +32,7 @@ object BuildUtils {
|
||||
Build.MODEL.contains("Android SDK built for") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
|
||||
|
||||
actual val sdkInt: Int
|
||||
get() = Build.VERSION.SDK_INT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
actual class CommonUri(private val uri: Uri) {
|
||||
actual val host: String?
|
||||
get() = uri.host
|
||||
|
||||
actual val fragment: String?
|
||||
get() = uri.fragment
|
||||
|
||||
actual val pathSegments: List<String>
|
||||
get() = uri.pathSegments
|
||||
|
||||
actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
|
||||
|
||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
|
||||
uri.getBooleanQueryParameter(key, defaultValue)
|
||||
|
||||
actual override fun toString(): String = uri.toString()
|
||||
|
||||
actual companion object {
|
||||
actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
|
||||
}
|
||||
|
||||
fun toUri(): Uri = uri
|
||||
}
|
||||
|
||||
actual fun CommonUri.toPlatformUri(): Any = this.toUri()
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import java.text.DateFormat
|
||||
|
||||
actual object DateFormatter {
|
||||
actual fun formatRelativeTime(timestampMillis: Long): String = DateUtils.getRelativeTimeSpanString(
|
||||
timestampMillis,
|
||||
nowMillis,
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
)
|
||||
.toString()
|
||||
|
||||
actual fun formatDateTime(timestampMillis: Long): String = DateUtils.formatDateTime(
|
||||
ContextServices.app,
|
||||
timestampMillis,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
|
||||
actual fun formatShortDate(timestampMillis: Long): String {
|
||||
val now = nowMillis
|
||||
val isWithin24Hours = (now - timestampMillis) <= DateUtils.DAY_IN_MILLIS
|
||||
|
||||
return if (isWithin24Hours) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(timestampMillis)
|
||||
} else {
|
||||
DateFormat.getDateInstance(DateFormat.SHORT).format(timestampMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,6 @@ package org.meshtastic.core.common.util
|
||||
import android.os.RemoteException
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
/**
|
||||
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
|
||||
* should not crash the process but are still unexpected.
|
||||
*/
|
||||
fun exceptionReporter(inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
|
||||
* interface.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.icu.util.LocaleData
|
||||
import android.icu.util.ULocale
|
||||
import android.os.Build
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
actual fun getSystemMeasurementSystem(): MeasurementSystem {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
// Android 14+ (API 34) introduced user-settable locale preferences.
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
try {
|
||||
val localePrefsClass = Class.forName("androidx.core.text.util.LocalePreferences")
|
||||
val getMeasurementSystemMethod =
|
||||
localePrefsClass.getMethod("getMeasurementSystem", Locale::class.java, Boolean::class.javaPrimitiveType)
|
||||
val result = getMeasurementSystemMethod.invoke(null, locale, true) as String
|
||||
return when (result) {
|
||||
"us",
|
||||
"uk",
|
||||
-> MeasurementSystem.IMPERIAL
|
||||
else -> MeasurementSystem.METRIC
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ignored: Exception) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
|
||||
LocaleData.MeasurementSystem.SI -> MeasurementSystem.METRIC
|
||||
else -> MeasurementSystem.IMPERIAL
|
||||
}
|
||||
} else {
|
||||
when (locale.country.uppercase(locale)) {
|
||||
"US",
|
||||
"LR",
|
||||
"MM",
|
||||
"GB",
|
||||
-> MeasurementSystem.IMPERIAL
|
||||
else -> MeasurementSystem.METRIC
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import android.net.InetAddresses
|
||||
import android.os.Build
|
||||
import android.util.Patterns
|
||||
|
||||
actual fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
} else {
|
||||
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
import android.os.Parcelable
|
||||
|
||||
actual typealias CommonParcelable = Parcelable
|
||||
|
||||
actual typealias CommonParcelize = kotlinx.parcelize.Parcelize
|
||||
|
||||
actual typealias CommonIgnoredOnParcel = kotlinx.parcelize.IgnoredOnParcel
|
||||
|
||||
actual typealias CommonParceler<T> = kotlinx.parcelize.Parceler<T>
|
||||
|
||||
actual typealias CommonTypeParceler<T, P> = kotlinx.parcelize.TypeParceler<T, P>
|
||||
|
||||
actual typealias CommonParcel = android.os.Parcel
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
/** Utility for checking build properties, such as emulator detection. */
|
||||
expect object BuildUtils {
|
||||
/** Whether the app is currently running on an emulator. */
|
||||
val isEmulator: Boolean
|
||||
|
||||
/** The SDK version of the current platform. On non-Android platforms, this returns 0. */
|
||||
val sdkInt: Int
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
|
||||
expect class CommonUri {
|
||||
val host: String?
|
||||
val fragment: String?
|
||||
val pathSegments: List<String>
|
||||
|
||||
fun getQueryParameter(key: String): String?
|
||||
|
||||
fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
|
||||
|
||||
override fun toString(): String
|
||||
|
||||
companion object {
|
||||
fun parse(uriString: String): CommonUri
|
||||
}
|
||||
}
|
||||
|
||||
/** Extension to convert platform Uri to CommonUri in Android source sets. */
|
||||
expect fun CommonUri.toPlatformUri(): Any
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
/** Platform-agnostic Date formatter utility. */
|
||||
expect object DateFormatter {
|
||||
/** Formats a timestamp into a relative "time ago" string. */
|
||||
fun formatRelativeTime(timestampMillis: Long): String
|
||||
|
||||
/** Formats a timestamp into a localized date and time string. */
|
||||
fun formatDateTime(timestampMillis: Long): String
|
||||
|
||||
/**
|
||||
* Formats a timestamp into a short date or time string.
|
||||
*
|
||||
* Typically shows time if within the last 24 hours, otherwise the date.
|
||||
*/
|
||||
fun formatShortDate(timestampMillis: Long): String
|
||||
}
|
||||
@@ -46,3 +46,15 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
|
||||
* should not crash the process but are still unexpected.
|
||||
*/
|
||||
fun exceptionReporter(inner: () -> Unit) {
|
||||
try {
|
||||
inner()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +14,11 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import org.meshtastic.core.model.Position
|
||||
import java.util.Locale
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
@@ -29,20 +26,34 @@ import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("PropertyNaming")
|
||||
@Suppress("MagicNumber")
|
||||
object GPSFormat {
|
||||
fun toDec(latitude: Double, longitude: Double): String =
|
||||
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
|
||||
fun toDec(latitude: Double, longitude: Double): String {
|
||||
// Simple decimal formatting for KMP
|
||||
fun Double.format(digits: Int): String {
|
||||
val multiplier = 10.0.pow(digits)
|
||||
val rounded = (this * multiplier).toLong() / multiplier
|
||||
return rounded.toString()
|
||||
}
|
||||
return "${latitude.format(5)}, ${longitude.format(5)}"
|
||||
}
|
||||
}
|
||||
|
||||
private const val EARTH_RADIUS_METERS = 6371e3
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun Double.toRadians(): Double = this * PI / 180.0
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun Double.toDegrees(): Double = this * 180.0 / PI
|
||||
|
||||
/** @return distance in meters along the surface of the earth (ish) */
|
||||
@Suppress("MagicNumber")
|
||||
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
|
||||
val lat1 = Math.toRadians(latitudeA)
|
||||
val lon1 = Math.toRadians(longitudeA)
|
||||
val lat2 = Math.toRadians(latitudeB)
|
||||
val lon2 = Math.toRadians(longitudeB)
|
||||
val lat1 = latitudeA.toRadians()
|
||||
val lon1 = longitudeA.toRadians()
|
||||
val lat2 = latitudeB.toRadians()
|
||||
val lon2 = longitudeB.toRadians()
|
||||
|
||||
val dLat = lat2 - lat1
|
||||
val dLon = lon2 - lon1
|
||||
@@ -53,10 +64,6 @@ fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, lon
|
||||
return EARTH_RADIUS_METERS * c
|
||||
}
|
||||
|
||||
// Same as above, but takes Mesh Position proto.
|
||||
@Suppress("MagicNumber")
|
||||
fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude)
|
||||
|
||||
/**
|
||||
* Computes the bearing in degrees between two points on Earth.
|
||||
*
|
||||
@@ -68,16 +75,16 @@ fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitud
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lon1Rad = Math.toRadians(lon1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val lon2Rad = Math.toRadians(lon2)
|
||||
val lat1Rad = lat1.toRadians()
|
||||
val lon1Rad = lon1.toRadians()
|
||||
val lat2Rad = lat2.toRadians()
|
||||
val lon2Rad = lon2.toRadians()
|
||||
|
||||
val dLon = lon2Rad - lon1Rad
|
||||
|
||||
val y = sin(dLon) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
|
||||
val bearing = Math.toDegrees(atan2(y, x))
|
||||
val bearing = atan2(y, x).toDegrees()
|
||||
|
||||
return (bearing + 360) % 360
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
/** Represents the system's preferred measurement system. */
|
||||
enum class MeasurementSystem {
|
||||
METRIC,
|
||||
IMPERIAL,
|
||||
}
|
||||
|
||||
/** returns the system's preferred measurement system. */
|
||||
expect fun getSystemMeasurementSystem(): MeasurementSystem
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.common.util
|
||||
|
||||
/** Validates if the given string is a valid network address (IP or domain). */
|
||||
expect fun String?.isValidAddress(): Boolean
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** Platform-agnostic Parcelable interface. */
|
||||
expect interface CommonParcelable
|
||||
|
||||
/** Platform-agnostic Parcelize annotation. */
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
expect annotation class CommonParcelize()
|
||||
|
||||
/** Platform-agnostic IgnoredOnParcel annotation. */
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
expect annotation class CommonIgnoredOnParcel()
|
||||
|
||||
/** Platform-agnostic Parceler interface. */
|
||||
expect interface CommonParceler<T> {
|
||||
fun create(parcel: CommonParcel): T
|
||||
|
||||
fun T.write(parcel: CommonParcel, flags: Int)
|
||||
}
|
||||
|
||||
/** Platform-agnostic TypeParceler annotation. */
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@Repeatable
|
||||
expect annotation class CommonTypeParceler<T, P : CommonParceler<in T>>()
|
||||
|
||||
/** Platform-agnostic Parcel representation for manual parceling (e.g. AIDL support). */
|
||||
expect class CommonParcel {
|
||||
fun readString(): String?
|
||||
|
||||
fun readInt(): Int
|
||||
|
||||
fun readLong(): Long
|
||||
|
||||
fun readFloat(): Float
|
||||
|
||||
fun createByteArray(): ByteArray?
|
||||
|
||||
fun writeByteArray(b: ByteArray?)
|
||||
}
|
||||
@@ -16,14 +16,14 @@
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
|
||||
import android.graphics.Color
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.GPSFormat
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.GPSFormat
|
||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||
import org.meshtastic.core.model.util.latLongToMeter
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
@@ -76,7 +76,9 @@ data class Node(
|
||||
val g = (num and 0x00FF00) shr 8
|
||||
val b = num and 0x0000FF
|
||||
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
|
||||
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
|
||||
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
return foreground to background
|
||||
}
|
||||
|
||||
val isUnknownUser
|
||||
@@ -130,7 +132,7 @@ data class Node(
|
||||
// @return bearing to the other position in degrees
|
||||
fun bearing(o: Node?): Int? = when {
|
||||
validPosition == null || o?.validPosition == null -> null
|
||||
else -> org.meshtastic.core.model.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
|
||||
else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt()
|
||||
}
|
||||
|
||||
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# `:core:model`
|
||||
# `:core:model` (Meshtastic Domain Models)
|
||||
|
||||
## Overview
|
||||
The `:core:model` module contains the domain models and Parcelable data classes used throughout the application and its API. These models are designed to be shared between the service and client applications via AIDL.
|
||||
The `:core:model` module is a **Kotlin Multiplatform (KMP)** library containing the domain models and data classes used throughout the application and its API. These models are platform-agnostic and designed to be shared across Android, JVM, and future supported platforms.
|
||||
|
||||
## Multiplatform Support
|
||||
Models in this module use the `CommonParcelable` and `CommonParcelize` abstractions from `:core:common`. This allows them to maintain Android `Parcelable` compatibility (via `@Parcelize`) while residing in `commonMain` and remaining accessible to non-Android targets.
|
||||
|
||||
## Key Models
|
||||
|
||||
@@ -14,9 +17,15 @@ The `:core:model` module contains the domain models and Parcelable data classes
|
||||
This module is a core dependency of `core:api` and most feature modules.
|
||||
|
||||
```kotlin
|
||||
// In commonMain
|
||||
implementation(projects.core.model)
|
||||
```
|
||||
|
||||
## Structure
|
||||
- **`commonMain`**: Contains the majority of domain models and logic.
|
||||
- **`androidMain`**: Contains Android-specific utilities and implementations for `expect` declarations.
|
||||
- **`androidUnitTest`**: Contains unit tests that require Android-specific features (like `Parcel` testing via Robolectric).
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region graph-->
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.kmp.library)
|
||||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
`maven-publish`
|
||||
@@ -25,48 +24,34 @@ plugins {
|
||||
|
||||
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
|
||||
|
||||
configure<LibraryExtension> {
|
||||
namespace = "org.meshtastic.core.model"
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
aidl = true
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.proto)
|
||||
api(projects.core.common)
|
||||
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
implementation(libs.kermit)
|
||||
api(libs.okio)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
api(libs.androidx.annotation)
|
||||
implementation(libs.zxing.core)
|
||||
}
|
||||
commonTest.dependencies { implementation(kotlin("test")) }
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Lowering minSdk to 21 for better compatibility with ATAK and other plugins
|
||||
minSdk = 21
|
||||
}
|
||||
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
|
||||
publishing { singleVariant("release") { withSourcesJar() } }
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("release") {
|
||||
from(components["release"])
|
||||
artifactId = "meshtastic-android-model"
|
||||
}
|
||||
// Modern KMP publication uses the project name as the artifactId by default.
|
||||
// We rename the publications to include the 'core-' prefix for consistency.
|
||||
publishing {
|
||||
publications.withType<MavenPublication>().configureEach {
|
||||
val baseId = artifactId
|
||||
if (baseId == "model") {
|
||||
artifactId = "meshtastic-android-model"
|
||||
} else if (baseId.startsWith("model-")) {
|
||||
artifactId = baseId.replace("model-", "meshtastic-android-model-")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.proto)
|
||||
api(projects.core.common)
|
||||
|
||||
api(libs.androidx.annotation)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.zxing.core)
|
||||
|
||||
testImplementation(libs.androidx.core.ktx)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
private val ONLINE_WINDOW_HOURS = 2.hours
|
||||
private val DAY_DURATION = 24.hours
|
||||
|
||||
/**
|
||||
@@ -94,13 +93,6 @@ private fun formatUptime(seconds: Long): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
actual val isDebug: Boolean = false
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber", "TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
||||
fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
|
||||
val multiFormatWriter = MultiFormatWriter()
|
||||
val url = getChannelUrl(false, shouldAdd)
|
||||
val bitMatrix = multiFormatWriter.encode(url.toString(), BarcodeFormat.QR_CODE, 960, 960)
|
||||
bitMatrix.toBitmap()
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e(ex) { "URL was too complex to render as barcode" }
|
||||
null
|
||||
}
|
||||
|
||||
private fun BitMatrix.toBitmap(): Bitmap {
|
||||
val width = width
|
||||
val height = height
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
val offset = y * width
|
||||
for (x in 0 until width) {
|
||||
// Black: 0xFF000000, White: 0xFFFFFFFF
|
||||
pixels[offset + x] = if (get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
actual fun platformRandomBytes(size: Int): ByteArray {
|
||||
val bytes = ByteArray(size)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
|
||||
fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
|
||||
|
||||
/** Bridge extension for Android clients. */
|
||||
fun Uri.dispatchMeshtasticUri(
|
||||
onChannel: (ChannelSet) -> Unit,
|
||||
onContact: (SharedContact) -> Unit,
|
||||
onInvalid: () -> Unit,
|
||||
) = this.toCommonUri().dispatchMeshtasticUri(onChannel, onContact, onInvalid)
|
||||
|
||||
/** Bridge extension for Android clients. */
|
||||
fun Uri.toChannelSet(): ChannelSet = this.toCommonUri().toChannelSet()
|
||||
|
||||
/** Bridge extension for Android clients. */
|
||||
fun Uri.toSharedContact(): SharedContact = this.toCommonUri().toSharedContact()
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.junit.Assert
|
||||
@@ -16,13 +16,15 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.meshtastic.core.model.util.isDebug
|
||||
|
||||
/**
|
||||
* Defines the capabilities and feature support based on the device firmware version.
|
||||
*
|
||||
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
|
||||
* Add new features here to ensure consistency across the app.
|
||||
*/
|
||||
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = BuildConfig.DEBUG) {
|
||||
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
|
||||
private val version = firmwareVersion?.let { DeviceVersion(it) }
|
||||
|
||||
private fun isSupported(minVersion: String): Boolean =
|
||||
@@ -19,11 +19,11 @@ package org.meshtastic.core.model
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.util.byteArrayOfInts
|
||||
import org.meshtastic.core.model.util.platformRandomBytes
|
||||
import org.meshtastic.core.model.util.xorHash
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config.LoRaConfig
|
||||
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
|
||||
import java.security.SecureRandom
|
||||
|
||||
data class Channel(val settings: ChannelSettings = default.settings, val loraConfig: LoRaConfig = default.loraConfig) {
|
||||
companion object {
|
||||
@@ -59,12 +59,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
|
||||
LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, hop_limit = 3, tx_enabled = true),
|
||||
)
|
||||
|
||||
fun getRandomKey(size: Int = 32): ByteString {
|
||||
val bytes = ByteArray(size)
|
||||
val random = SecureRandom()
|
||||
random.nextBytes(bytes)
|
||||
return bytes.toByteString()
|
||||
}
|
||||
fun getRandomKey(size: Int = 32): ByteString = platformRandomBytes(size).toByteString()
|
||||
}
|
||||
|
||||
// Return the name of our channel as a human readable string. If empty string, assume "Default" per mesh.proto spec
|
||||
@@ -112,7 +107,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
|
||||
|
||||
/** Given a channel name and psk, return the (0 to 255) hash for that channel */
|
||||
val hash: Int
|
||||
get() = xorHash(name.toByteArray()) xor xorHash(psk.toByteArray())
|
||||
get() = xorHash(name.encodeToByteArray()) xor xorHash(psk.toByteArray())
|
||||
|
||||
val channelNum: Int
|
||||
get() = loraConfig.channelNum(name)
|
||||
@@ -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,18 +14,21 @@
|
||||
* 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
|
||||
|
||||
package com.geeksville.mesh.model
|
||||
import org.meshtastic.core.common.util.CommonParcelable
|
||||
import org.meshtastic.core.common.util.CommonParcelize
|
||||
|
||||
@CommonParcelize
|
||||
data class Contact(
|
||||
val contactKey: String,
|
||||
val shortName: String,
|
||||
val longName: String,
|
||||
val lastMessageTime: String?,
|
||||
val lastMessageTime: Long?,
|
||||
val lastMessageText: String?,
|
||||
val unreadCount: Int,
|
||||
val messageCount: Int,
|
||||
val isMuted: Boolean,
|
||||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
)
|
||||
) : CommonParcelable
|
||||
@@ -16,15 +16,15 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import kotlinx.serialization.Serializable
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonIgnoredOnParcel
|
||||
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.nowMillis
|
||||
import org.meshtastic.core.model.util.ByteStringParceler
|
||||
import org.meshtastic.core.model.util.ByteStringSerializer
|
||||
@@ -32,8 +32,8 @@ import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
@Parcelize
|
||||
enum class MessageStatus : Parcelable {
|
||||
@CommonParcelize
|
||||
enum class MessageStatus : CommonParcelable {
|
||||
UNKNOWN, // Not set for this message
|
||||
RECEIVED, // Came in from the mesh
|
||||
QUEUED, // Waiting to send to the mesh as soon as we connect to the device
|
||||
@@ -46,11 +46,11 @@ enum class MessageStatus : Parcelable {
|
||||
|
||||
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
|
||||
@Serializable
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class DataPacket(
|
||||
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
|
||||
@Serializable(with = ByteStringSerializer::class)
|
||||
@TypeParceler<ByteString?, ByteStringParceler>
|
||||
@CommonTypeParceler<ByteString?, ByteStringParceler>
|
||||
var bytes: ByteString?,
|
||||
// A port number for this packet
|
||||
var dataType: Int,
|
||||
@@ -70,13 +70,13 @@ data class DataPacket(
|
||||
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
|
||||
var emoji: Int = 0,
|
||||
@Serializable(with = ByteStringSerializer::class)
|
||||
@TypeParceler<ByteString?, ByteStringParceler>
|
||||
@CommonTypeParceler<ByteString?, ByteStringParceler>
|
||||
var sfppHash: ByteString? = null,
|
||||
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
|
||||
var transportMechanism: Int = 0,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
|
||||
fun readFromParcel(parcel: Parcel) {
|
||||
fun readFromParcel(parcel: CommonParcel) {
|
||||
to = parcel.readString()
|
||||
bytes = ByteStringParceler.create(parcel)
|
||||
dataType = parcel.readInt()
|
||||
@@ -102,21 +102,21 @@ data class DataPacket(
|
||||
|
||||
hopLimit = parcel.readInt()
|
||||
channel = parcel.readInt()
|
||||
wantAck = parcel.readInt() != 0
|
||||
wantAck = (parcel.readInt() != 0)
|
||||
hopStart = parcel.readInt()
|
||||
snr = parcel.readFloat()
|
||||
rssi = parcel.readInt()
|
||||
replyId = if (parcel.readInt() == 0) null else parcel.readInt()
|
||||
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
|
||||
relays = parcel.readInt()
|
||||
viaMqtt = parcel.readInt() != 0
|
||||
viaMqtt = (parcel.readInt() != 0)
|
||||
emoji = parcel.readInt()
|
||||
sfppHash = ByteStringParceler.create(parcel)
|
||||
transportMechanism = parcel.readInt()
|
||||
}
|
||||
|
||||
/** If there was an error with this message, this string describes what was wrong. */
|
||||
@IgnoredOnParcel var errorMessage: String? = null
|
||||
@CommonIgnoredOnParcel var errorMessage: String? = null
|
||||
|
||||
/** Syntactic sugar to make it easy to create text messages */
|
||||
constructor(
|
||||
@@ -173,7 +173,7 @@ data class DataPacket(
|
||||
}
|
||||
|
||||
val hopsAway: Int
|
||||
get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit
|
||||
get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit
|
||||
|
||||
companion object {
|
||||
// Special node IDs that can be used for sending messages
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
@@ -16,11 +16,11 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.meshtastic.core.common.util.CommonParcelable
|
||||
import org.meshtastic.core.common.util.CommonParcelize
|
||||
|
||||
// MyNodeInfo sent via special protobuf from radio
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class MyNodeInfo(
|
||||
val myNodeNum: Int,
|
||||
val hasGPS: Boolean,
|
||||
@@ -37,7 +37,7 @@ data class MyNodeInfo(
|
||||
val airUtilTx: Float,
|
||||
val deviceId: String?,
|
||||
val pioEnv: String? = null,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
/** A human readable description of the software/hardware version */
|
||||
val firmwareString: String
|
||||
get() = "$model $firmwareVersion"
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -14,7 +14,6 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -16,13 +16,12 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.meshtastic.core.common.util.CommonParcelable
|
||||
import org.meshtastic.core.common.util.CommonParcelize
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
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.onlineTimeThreshold
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
@@ -31,7 +30,7 @@ import org.meshtastic.proto.HardwareModel
|
||||
// model objects that directly map to the corresponding protobufs
|
||||
//
|
||||
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class MeshUser(
|
||||
val id: String,
|
||||
val longName: String,
|
||||
@@ -39,7 +38,7 @@ data class MeshUser(
|
||||
val hwModel: HardwareModel,
|
||||
val isLicensed: Boolean = false,
|
||||
val role: Int = 0,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
|
||||
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
|
||||
"longName=${longName.anonymize}, " +
|
||||
@@ -66,7 +65,7 @@ data class MeshUser(
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class Position(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
@@ -76,7 +75,7 @@ data class Position(
|
||||
val groundSpeed: Int = 0,
|
||||
val groundTrack: Int = 0, // "heading"
|
||||
val precisionBits: Int = 0,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
@@ -124,7 +123,7 @@ data class Position(
|
||||
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class DeviceMetrics(
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val batteryLevel: Int = 0,
|
||||
@@ -132,7 +131,7 @@ data class DeviceMetrics(
|
||||
val channelUtilization: Float,
|
||||
val airUtilTx: Float,
|
||||
val uptimeSeconds: Int,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
companion object {
|
||||
@Suppress("MagicNumber")
|
||||
fun currentTime() = nowSeconds.toInt()
|
||||
@@ -152,7 +151,7 @@ data class DeviceMetrics(
|
||||
)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class EnvironmentMetrics(
|
||||
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
|
||||
val temperature: Float?,
|
||||
@@ -166,7 +165,7 @@ data class EnvironmentMetrics(
|
||||
val iaq: Int?,
|
||||
val lux: Float? = null,
|
||||
val uvLux: Float? = null,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
@Suppress("MagicNumber")
|
||||
companion object {
|
||||
fun currentTime() = nowSeconds.toInt()
|
||||
@@ -189,7 +188,7 @@ data class EnvironmentMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@CommonParcelize
|
||||
data class NodeInfo(
|
||||
val num: Int, // This is immutable, and used as a key
|
||||
var user: MeshUser? = null,
|
||||
@@ -202,7 +201,7 @@ data class NodeInfo(
|
||||
var environmentMetrics: EnvironmentMetrics? = null,
|
||||
var hopsAway: Int = 0,
|
||||
var nodeStatus: String? = null,
|
||||
) : Parcelable {
|
||||
) : CommonParcelable {
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val colors: Pair<Int, Int>
|
||||
@@ -211,7 +210,9 @@ data class NodeInfo(
|
||||
val g = (num and 0x00FF00) shr 8
|
||||
val b = num and 0x0000FF
|
||||
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
|
||||
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
|
||||
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
return foreground to background
|
||||
}
|
||||
|
||||
val batteryLevel
|
||||
@@ -222,7 +223,7 @@ data class NodeInfo(
|
||||
|
||||
@Suppress("ImplicitDefaultLocale")
|
||||
val batteryStr
|
||||
get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
|
||||
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
|
||||
|
||||
/** true if the device was heard from recently */
|
||||
val isOnline: Boolean
|
||||
@@ -255,14 +256,13 @@ data class NodeInfo(
|
||||
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
|
||||
when {
|
||||
dist == 0 -> null // same point
|
||||
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 ->
|
||||
"%.0f m".format(dist.toDouble())
|
||||
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "$dist m"
|
||||
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 ->
|
||||
"%.1f km".format(dist / 1000.0)
|
||||
"${(dist / 100).toDouble() / 10.0} km"
|
||||
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 ->
|
||||
"%.0f ft".format(dist.toDouble() * 3.281)
|
||||
"${(dist.toDouble() * 3.281).toInt()} ft"
|
||||
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 ->
|
||||
"%.1f mi".format(dist / 1609.34)
|
||||
"${(dist / 160.9).toInt() / 10.0} mi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,15 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.util.Base64
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
|
||||
fun ByteString.encodeToString(): String = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP)
|
||||
fun ByteString.encodeToString(): String = base64()
|
||||
|
||||
/**
|
||||
* Decodes a Base64 string into a [ByteString].
|
||||
*
|
||||
* @throws IllegalArgumentException if the string is not valid Base64.
|
||||
*/
|
||||
fun String.base64ToByteString(): ByteString = Base64.decode(this, Base64.NO_WRAP).toByteString()
|
||||
fun String.base64ToByteString(): ByteString =
|
||||
decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string: $this")
|
||||
@@ -16,8 +16,6 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.os.Parcel
|
||||
import kotlinx.parcelize.Parceler
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ByteArraySerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
@@ -25,6 +23,8 @@ import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonParcel
|
||||
import org.meshtastic.core.common.util.CommonParceler
|
||||
|
||||
/** Serializer for Okio [ByteString] using kotlinx.serialization */
|
||||
object ByteStringSerializer : KSerializer<ByteString> {
|
||||
@@ -40,10 +40,10 @@ object ByteStringSerializer : KSerializer<ByteString> {
|
||||
}
|
||||
|
||||
/** Parceler for Okio [ByteString] for Android Parcelable support */
|
||||
object ByteStringParceler : Parceler<ByteString?> {
|
||||
override fun create(parcel: Parcel): ByteString? = parcel.createByteArray()?.toByteString()
|
||||
object ByteStringParceler : CommonParceler<ByteString?> {
|
||||
override fun create(parcel: CommonParcel): ByteString? = parcel.createByteArray()?.toByteString()
|
||||
|
||||
override fun ByteString?.write(parcel: Parcel, flags: Int) {
|
||||
override fun ByteString?.write(parcel: CommonParcel, flags: Int) {
|
||||
parcel.writeByteArray(this?.toByteArray())
|
||||
}
|
||||
}
|
||||
@@ -14,31 +14,24 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config.LoRaConfig
|
||||
import java.net.MalformedURLException
|
||||
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
|
||||
/**
|
||||
* Return a [ChannelSet] that represents the ChannelSet encoded by the URL.
|
||||
*
|
||||
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
|
||||
* @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL
|
||||
*/
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toChannelSet(): ChannelSet {
|
||||
@Throws(MalformedMeshtasticUrlException::class)
|
||||
fun CommonUri.toChannelSet(): ChannelSet {
|
||||
val h = host ?: ""
|
||||
val isCorrectHost =
|
||||
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
|
||||
@@ -46,13 +39,16 @@ fun Uri.toChannelSet(): ChannelSet {
|
||||
val isCorrectPath = segments.any { it.equals("e", ignoreCase = true) }
|
||||
|
||||
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
throw MalformedMeshtasticUrlException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
|
||||
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
|
||||
// This gracefully handles those cases until the newer version are generally available/used.
|
||||
val fragmentBytes = Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)
|
||||
val url = ChannelSet.ADAPTER.decode(fragmentBytes.toByteString())
|
||||
val fragmentBase64 = fragment!!.substringBefore('?').replace('-', '+').replace('_', '/')
|
||||
val fragmentBytes =
|
||||
fragmentBase64.decodeBase64()
|
||||
?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment: $fragmentBase64")
|
||||
val url = ChannelSet.ADAPTER.decode(fragmentBytes)
|
||||
val shouldAdd =
|
||||
fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true")
|
||||
?: getBooleanQueryParameter("add", false)
|
||||
@@ -85,35 +81,10 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
|
||||
*
|
||||
* @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes
|
||||
*/
|
||||
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri {
|
||||
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri {
|
||||
val channelBytes = ChannelSet.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
|
||||
val enc = channelBytes.toByteString().base64Url()
|
||||
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
|
||||
val query = if (shouldAdd) "?add=true" else ""
|
||||
return Uri.parse("$p$query#$enc")
|
||||
}
|
||||
|
||||
fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
|
||||
val multiFormatWriter = MultiFormatWriter()
|
||||
val bitMatrix =
|
||||
multiFormatWriter.encode(getChannelUrl(false, shouldAdd).toString(), BarcodeFormat.QR_CODE, 960, 960)
|
||||
bitMatrix.toBitmap()
|
||||
} catch (ex: Throwable) {
|
||||
Logger.e { "URL was too complex to render as barcode" }
|
||||
null
|
||||
}
|
||||
|
||||
private fun BitMatrix.toBitmap(): Bitmap {
|
||||
val width = width
|
||||
val height = height
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
val offset = y * width
|
||||
for (x in 0 until width) {
|
||||
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return bitmap
|
||||
return CommonUri.parse("$p$query#$enc")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
expect val isDebug: Boolean
|
||||
@@ -18,11 +18,11 @@
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.icu.util.LocaleData
|
||||
import android.icu.util.ULocale
|
||||
import org.meshtastic.core.common.util.MeasurementSystem
|
||||
import org.meshtastic.core.common.util.getSystemMeasurementSystem
|
||||
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: Int) {
|
||||
METER("m", multiplier = 1F, DisplayUnits.METRIC.value),
|
||||
KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC.value),
|
||||
@@ -31,22 +31,10 @@ enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: I
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun getFromLocale(locale: Locale = Locale.getDefault()): DisplayUnits =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
|
||||
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
|
||||
LocaleData.MeasurementSystem.SI -> DisplayUnits.METRIC
|
||||
else -> DisplayUnits.IMPERIAL
|
||||
}
|
||||
} else {
|
||||
when (locale.country.uppercase(locale)) {
|
||||
"US",
|
||||
"LR",
|
||||
"MM",
|
||||
"GB",
|
||||
-> DisplayUnits.IMPERIAL
|
||||
else -> DisplayUnits.METRIC
|
||||
}
|
||||
}
|
||||
fun getFromLocale(): DisplayUnits = when (getSystemMeasurementSystem()) {
|
||||
MeasurementSystem.METRIC -> DisplayUnits.METRIC
|
||||
MeasurementSystem.IMPERIAL -> DisplayUnits.IMPERIAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import org.meshtastic.core.model.BuildConfig
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Telemetry
|
||||
@@ -49,13 +48,14 @@ fun MeshPacket.toOneLineString(): String {
|
||||
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
|
||||
}
|
||||
|
||||
fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
|
||||
fun Any.toPIIString() = if (!isDebug) {
|
||||
"<PII?>"
|
||||
} else {
|
||||
this.toOneLineString()
|
||||
}
|
||||
|
||||
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||
@Suppress("MagicNumber")
|
||||
fun ByteArray.toHexString() = joinToString("") { it.toUByte().toString(16).padStart(2, '0') }
|
||||
|
||||
private const val MPS_TO_KMPH = 3.6f
|
||||
private const val KM_TO_MILES = 0.621371f
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.model.Position
|
||||
|
||||
/** @return distance in meters along the surface of the earth (ish) */
|
||||
@Suppress("MagicNumber")
|
||||
fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude)
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
/** Exception thrown when a Meshtastic URL cannot be parsed. */
|
||||
class MalformedMeshtasticUrlException(message: String) : Exception(message)
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/**
|
||||
* Utility class to map [MeshPacket] protobufs to [DataPacket] domain models.
|
||||
*
|
||||
* This class is platform-agnostic and can be used in shared logic.
|
||||
*/
|
||||
class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
|
||||
|
||||
/** Maps a [MeshPacket] to a [DataPacket], or returns null if the packet has no decoded data. */
|
||||
fun toDataPacket(packet: MeshPacket): DataPacket? {
|
||||
val decoded = packet.decoded ?: return null
|
||||
return DataPacket(
|
||||
from = nodeIdLookup.toNodeID(packet.from),
|
||||
to = nodeIdLookup.toNodeID(packet.to),
|
||||
time = packet.rx_time * 1000L,
|
||||
id = packet.id,
|
||||
dataType = decoded.portnum.value,
|
||||
bytes = decoded.payload.toByteArray().toByteString(),
|
||||
hopLimit = packet.hop_limit,
|
||||
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
|
||||
wantAck = packet.want_ack == true,
|
||||
hopStart = packet.hop_start,
|
||||
snr = packet.rx_snr,
|
||||
rssi = packet.rx_rssi,
|
||||
replyId = decoded.reply_id,
|
||||
relayNode = packet.relay_node,
|
||||
viaMqtt = packet.via_mqtt == true,
|
||||
emoji = decoded.emoji,
|
||||
transportMechanism = packet.transport_mechanism.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
/** Interface for looking up Node IDs from Node Numbers. */
|
||||
interface NodeIdLookup {
|
||||
/** Returns the Node ID (hex string) for the given [nodeNum]. */
|
||||
fun toNodeID(nodeNum: Int): String
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
expect fun platformRandomBytes(size: Int): ByteArray
|
||||
@@ -14,32 +14,31 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions", "SwallowedException", "TooGenericExceptionCaught")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import java.net.MalformedURLException
|
||||
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
|
||||
/**
|
||||
* Return a [SharedContact] that represents the contact encoded by the URL.
|
||||
*
|
||||
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
|
||||
* @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL
|
||||
*/
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toSharedContact(): SharedContact {
|
||||
@Throws(MalformedMeshtasticUrlException::class)
|
||||
fun CommonUri.toSharedContact(): SharedContact {
|
||||
checkSharedContactUrl()
|
||||
val data = fragment!!.substringBefore('?')
|
||||
return decodeSharedContactData(data)
|
||||
}
|
||||
|
||||
@Throws(MalformedURLException::class)
|
||||
private fun Uri.checkSharedContactUrl() {
|
||||
@Throws(MalformedMeshtasticUrlException::class)
|
||||
private fun CommonUri.checkSharedContactUrl() {
|
||||
val h = host?.lowercase() ?: ""
|
||||
val isCorrectHost = h == MESHTASTIC_HOST || h == "www.$MESHTASTIC_HOST"
|
||||
val segments = pathSegments
|
||||
@@ -47,41 +46,40 @@ private fun Uri.checkSharedContactUrl() {
|
||||
|
||||
val frag = fragment
|
||||
if (frag.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
|
||||
throw MalformedURLException(
|
||||
throw MalformedMeshtasticUrlException(
|
||||
"Not a valid Meshtastic URL: host=$h, segments=$segments, hasFragment=${!frag.isNullOrBlank()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(MalformedURLException::class)
|
||||
@Suppress("ThrowsCount")
|
||||
@Throws(MalformedMeshtasticUrlException::class)
|
||||
private fun decodeSharedContactData(data: String): SharedContact {
|
||||
val decodedBytes =
|
||||
try {
|
||||
// We use a more lenient decoding for the input to handle variations from different clients
|
||||
Base64.decode(data, Base64.DEFAULT or Base64.URL_SAFE)
|
||||
val sanitized = data.replace('-', '+').replace('_', '/')
|
||||
sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
val ex =
|
||||
MalformedURLException(
|
||||
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
|
||||
)
|
||||
ex.initCause(e)
|
||||
throw ex
|
||||
throw MalformedMeshtasticUrlException(
|
||||
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
SharedContact.ADAPTER.decode(decodedBytes.toByteString())
|
||||
} catch (e: java.io.IOException) {
|
||||
val ex = MalformedURLException("Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}")
|
||||
ex.initCause(e)
|
||||
throw ex
|
||||
SharedContact.ADAPTER.decode(decodedBytes)
|
||||
} catch (e: Exception) {
|
||||
throw MalformedMeshtasticUrlException(
|
||||
"Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a [SharedContact] to its corresponding URI representation. */
|
||||
fun SharedContact.getSharedContactUrl(): Uri {
|
||||
fun SharedContact.getSharedContactUrl(): CommonUri {
|
||||
val bytes = SharedContact.ADAPTER.encode(this)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return Uri.parse("$CONTACT_URL_PREFIX$enc")
|
||||
val enc = bytes.toByteString().base64Url()
|
||||
return CommonUri.parse("$CONTACT_URL_PREFIX$enc")
|
||||
}
|
||||
|
||||
/** Compares two [User] objects and returns a string detailing the differences. */
|
||||
@@ -130,4 +128,4 @@ fun userFieldsToString(user: User): String {
|
||||
return fieldLines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
|
||||
private fun ByteString.base64String(): String = base64()
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import org.meshtastic.core.common.util.nowInstant
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
private val ONLINE_WINDOW_HOURS = 2.hours
|
||||
|
||||
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()
|
||||
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.net.Uri
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
@@ -29,7 +29,11 @@ import org.meshtastic.proto.SharedContact
|
||||
* @param onContact Callback if the URI is a Shared Contact.
|
||||
* @return True if the URI was handled (matched a supported path), false otherwise.
|
||||
*/
|
||||
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
|
||||
fun handleMeshtasticUri(
|
||||
uri: CommonUri,
|
||||
onChannel: (CommonUri) -> Unit = {},
|
||||
onContact: (CommonUri) -> Unit = {},
|
||||
): Boolean {
|
||||
val h = uri.host ?: ""
|
||||
val isCorrectHost =
|
||||
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
|
||||
@@ -56,7 +60,7 @@ fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri
|
||||
* @param onContact Callback when successfully parsed as a [SharedContact].
|
||||
* @param onInvalid Callback when parsing fails or the URI is not a Meshtastic URL.
|
||||
*/
|
||||
fun Uri.dispatchMeshtasticUri(
|
||||
fun CommonUri.dispatchMeshtasticUri(
|
||||
onChannel: (ChannelSet) -> Unit,
|
||||
onContact: (SharedContact) -> Unit,
|
||||
onInvalid: () -> Unit,
|
||||
@@ -20,9 +20,9 @@
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="M12 7.5C12.69 7.5 13.27 7.73 13.76 8.2S14.5 9.27 14.5 10C14.5 11.05 14 11.81 13 12.28V21H11V12.28C10 11.81 9.5 11.05 9.5 10C9.5 9.27 9.76 8.67 10.24 8.2S11.31 7.5 12 7.5M16.69 5.3C17.94 6.55 18.61 8.11 18.7 10C18.7 11.8 18.03 13.38 16.69 14.72L15.5 13.5C16.5 12.59 17 11.42 17 10C17 8.67 16.5 7.5 15.5 6.5L16.69 5.3M6.09 4.08C4.5 5.67 3.7 7.64 3.7 10S4.5 14.3 6.09 15.89L4.92 17.11C3 15.08 2 12.7 2 10C2 7.3 3 4.94 4.92 2.91L6.09 4.08M19.08 2.91C21 4.94 22 7.3 22 10C22 12.8 21 15.17 19.08 17.11L17.91 15.89C19.5 14.3 20.3 12.33 20.3 10S19.5 5.67 17.91 4.08L19.08 2.91M7.31 5.3L8.5 6.5C7.5 7.42 7 8.58 7 10C7 11.33 7.5 12.5 8.5 13.5L7.31 14.72C5.97 13.38 5.3 11.8 5.3 10C5.3 8.2 5.97 6.64 7.31 5.3Z"
|
||||
android:fillAlpha=".5"
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="M14,20H6V6H14M14.67,4H13V2H7V4H5.33C4.6,4 4,4.6 4,5.33V20.67C4,21.4 4.6,22 5.33,22H14.67C15.4,22 16,21.4 16,20.67V5.33C16,4.6 15.4,4 14.67,4M21,7H19V13H21V8M21,15H19V17H21V15Z"
|
||||
android:fillAlpha="0.5"
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16M15,7H9V10H15V7M15,11.5H9V14.5H15V11.5Z"
|
||||
android:fillAlpha="0.5"
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16"
|
||||
android:fillAlpha="0.5"
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user