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:
James Rich 2026-02-24 06:37:33 -06:00 committed by GitHub
parent b3f88bd94f
commit d408964f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
144 changed files with 1460 additions and 664 deletions

View file

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

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.model
data class Contact(
val contactKey: String,
val shortName: String,
val longName: String,
val lastMessageTime: String?,
val lastMessageText: String?,
val unreadCount: Int,
val messageCount: Int,
val isMuted: Boolean,
val isUnmessageable: Boolean,
val nodeColors: Pair<Int, Int>? = null,
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,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,
)

View file

@ -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" }
}
}
}

View file

@ -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"

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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,
)
}
}
}

View file

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

View file

@ -1,169 +0,0 @@
/*
* 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 com.geeksville.mesh.ui.contact
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
val handleBack: () -> Unit = {
val currentEntry = navController.currentBackStackEntry
val isContactsRoute = currentEntry?.destination?.hasRoute<ContactsRoutes.Contacts>() == true
// Check if we navigated here from another screen (e.g., from Nodes or Map)
val previousEntry = navController.previousBackStackEntry
val isFromDifferentGraph = previousEntry?.destination?.hasRoute<ContactsRoutes.ContactsGraph>() == false
if (isFromDifferentGraph && !isContactsRoute) {
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
navController.navigateUp()
} else {
// Close the detail pane within the adaptive scaffold
scope.launch { navigator.navigateBack(backNavigationBehavior) }
}
}
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
LaunchedEffect(initialContactKey) {
if (initialContactKey != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
}
}
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents.collect { event ->
if (
event is ScrollToTopEvent.ConversationsTabPressed &&
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail
) {
if (navigator.canNavigateBack(backNavigationBehavior)) {
navigator.navigateBack(backNavigationBehavior)
} else {
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
}
}
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ContactsScreen(
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToMessages = { contactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
},
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
activeContactKey = navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
key(contactKey) {
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
}
} ?: PlaceholderScreen()
}
},
)
}
@Composable
private fun PlaceholderScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(
imageVector = MeshtasticIcons.Conversations,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.conversations),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -1,243 +0,0 @@
/*
* 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 com.geeksville.mesh.ui.contact
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeOff
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.Contact
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ChannelSet
@Suppress("LongMethod")
@Composable
fun ContactItem(
contact: Contact,
selected: Boolean,
modifier: Modifier = Modifier,
isActive: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onNodeChipClick: () -> Unit = {},
channels: ChannelSet? = null,
) = with(contact) {
val isOutlined = !selected && !isActive
val colors =
if (isOutlined) {
CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
} else {
val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
CardDefaults.cardColors(containerColor = containerColor)
}
val border =
if (isOutlined) {
CardDefaults.outlinedCardBorder()
} else {
null
}
Card(
modifier =
modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics { contentDescription = shortName },
shape = RoundedCornerShape(12.dp),
colors = colors,
border = border,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)
ChatMetadata(modifier = Modifier.padding(top = 4.dp), contact = contact)
}
}
}
@Composable
private fun ContactHeader(
contact: Contact,
channels: ChannelSet?,
modifier: Modifier = Modifier,
onNodeChipClick: () -> Unit = {},
) {
val colors =
if (contact.nodeColors != null) {
AssistChipDefaults.assistChipColors(
labelColor = Color(contact.nodeColors.first),
containerColor = Color(contact.nodeColors.second),
)
} else {
AssistChipDefaults.assistChipColors()
}
Row(modifier = modifier.padding(0.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(
onClick = onNodeChipClick,
modifier =
Modifier.width(IntrinsicSize.Min).height(32.dp).semantics { contentDescription = contact.shortName },
label = {
Text(
text = contact.shortName,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
)
},
colors = colors,
)
// Show unlock icon for broadcast with default PSK
val isBroadcast = with(contact.contactKey) { getOrNull(1) == '^' || endsWith("^all") || endsWith("^broadcast") }
if (isBroadcast && channels != null) {
val channelIndex = contact.contactKey[0].digitToIntOrNull()
channelIndex?.let { index -> SecurityIcon(channels, index) }
}
Text(
modifier = Modifier.padding(start = 8.dp).weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = contact.longName,
)
Text(
text = contact.lastMessageTime.orEmpty(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier,
)
}
}
private const val UNREAD_MESSAGE_LIMIT = 99
@Composable
private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = contact.lastMessageText.orEmpty(),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
AnimatedVisibility(visible = contact.isMuted) {
Icon(
modifier = Modifier.padding(start = 4.dp).size(20.dp),
imageVector = Icons.AutoMirrored.TwoTone.VolumeOff,
contentDescription = null,
)
}
AnimatedVisibility(modifier = Modifier.padding(start = 4.dp), visible = contact.unreadCount > 0) {
val text =
if (contact.unreadCount > UNREAD_MESSAGE_LIMIT) {
"$UNREAD_MESSAGE_LIMIT+"
} else {
contact.unreadCount.toString()
}
Text(
text = text,
modifier =
Modifier.background(MaterialTheme.colorScheme.primary, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
}
}
@PreviewLightDark
@Composable
private fun ContactItemPreview() {
val sampleContact =
Contact(
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
lastMessageTime = "Mon",
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
)
val contactsList =
listOf(
sampleContact,
sampleContact.copy(
shortName = "0",
longName = "A very long contact name that should be truncated.",
lastMessageTime = "15 minutes ago",
),
)
AppTheme { Column { contactsList.forEach { contact -> ContactItem(contact = contact, selected = false) } } }
}

View file

@ -1,615 +0,0 @@
/*
* 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 com.geeksville.mesh.ui.contact
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.model.UIViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.close_selection
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.currently
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_selection
import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_status_always
import org.meshtastic.core.resources.mute_status_muted_for_days
import org.meshtastic.core.resources.mute_status_muted_for_hours
import org.meshtastic.core.resources.mute_status_unmuted
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel(),
uIViewModel: UIViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeContactKey: String? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
var showMuteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
// State for managing selected contacts
val selectedContactKeys = remember { mutableStateListOf<String>() }
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
// State for contacts list
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
// Create channel placeholders (always show broadcast contacts, even when empty)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val channelPlaceholders =
remember(channels.settings.size) {
(0 until channels.settings.size).map { ch ->
Contact(
contactKey = "$ch^all",
shortName = "$ch",
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
lastMessageTime = "",
lastMessageText = "",
unreadCount = 0,
messageCount = 0,
isMuted = false,
isUnmessageable = false,
nodeColors = null,
)
}
}
val contactsListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents?.collectLatest { event ->
if (event is ScrollToTopEvent.ConversationsTabPressed) {
contactsListState.smartScrollToTop(coroutineScope)
}
}
}
// Derived state for selected contacts and count
val selectedContacts =
remember(pagedContacts.itemCount, selectedContactKeys) {
(0 until pagedContacts.itemCount)
.mapNotNull { pagedContacts[it] }
.filter { it.contactKey in selectedContactKeys }
}
// Get message count directly from repository for selected contacts
var selectedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) {
selectedCount = viewModel.getTotalMessageCount(selectedContactKeys.toList())
}
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { uIViewModel.clearRequestChannelUrl() }) }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
if (isSelectionModeActive) {
// If in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
} else {
// If not in selection mode, navigate to messages
onNavigateToMessages(contact.contactKey)
}
}
val onNodeChipClick: (Contact) -> Unit = { contact ->
if (contact.contactKey.contains("!")) {
// if it's a node, look up the nodeNum including the !
val nodeKey = contact.contactKey.substring(1)
val node = viewModel.getNode(nodeKey)
onNavigateToNodeDetails(node.num)
} else {
// Channels
}
}
val onContactLongClick: (Contact) -> Unit = { contact ->
// Enter selection mode and select the item on long press
if (!isSelectionModeActive) {
selectedContactKeys.add(contact.contactKey)
} else {
// If already in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
}
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.conversations),
ourNode = ourNode,
showNodeChip = ourNode != null && connectionState.isConnected(),
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = { onClickNodeChip(it.num) },
)
},
floatingActionButton = {
if (connectionState.isConnected()) {
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uri ->
uIViewModel.handleScannedUri(uri) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},
onShareChannels = onNavigateToShare,
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
if (isSelectionModeActive) {
// Display selection toolbar when in selection mode
SelectionToolbar(
selectedCount = selectedContactKeys.size,
onCloseSelection = { selectedContactKeys.clear() },
onMuteSelected = { showMuteDialog = true },
onDeleteSelected = { showDeleteDialog = true },
onSelectAll = {
selectedContactKeys.clear()
selectedContactKeys.addAll(
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
)
},
isAllMuted = isAllMuted, // Pass the derived state
)
}
ContactListViewPaged(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
activeContactKey = activeContactKey,
onClick = onContactClick,
onLongClick = onContactLongClick,
onNodeChipClick = onNodeChipClick,
listState = contactsListState,
channels = channels,
)
}
}
if (showDeleteDialog) {
DeleteConfirmationDialog(
selectedCount = selectedCount,
onDismiss = { showDeleteDialog = false },
onConfirm = {
showDeleteDialog = false
viewModel.deleteContacts(selectedContactKeys.toList())
selectedContactKeys.clear()
},
)
}
// Get contact settings for the dialog
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
if (showMuteDialog) {
MuteNotificationsDialog(
selectedContactKeys = selectedContactKeys.toList(),
contactSettings = contactSettings,
onDismiss = { showMuteDialog = false },
onConfirm = { muteUntil ->
showMuteDialog = false
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
selectedContactKeys.clear()
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun MuteNotificationsDialog(
selectedContactKeys: List<String>,
contactSettings: Map<String, ContactSettings>,
onDismiss: () -> Unit,
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
) {
// Options for mute duration
val muteOptions = remember {
listOf(
Res.string.unmute to 0L,
Res.string.mute_8_hours to TimeConstants.EIGHT_HOURS.inWholeMilliseconds,
Res.string.mute_1_week to 7.days.inWholeMilliseconds,
Res.string.mute_always to Long.MAX_VALUE,
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableIntStateOf(2) } // Default to "Always"
MeshtasticDialog(
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
titleRes = Res.string.mute_notifications,
confirmTextRes = Res.string.okay,
onConfirm = {
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
onConfirm(selectedMuteDuration)
onDismiss() // Dismiss the dialog after confirming
},
dismissTextRes = Res.string.cancel,
text = {
Column {
// Show current mute status
selectedContactKeys.forEach { contactKey ->
contactSettings[contactKey]?.let { settings ->
val now = nowMillis
val statusText =
when {
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
val remaining = settings.muteUntil - now
if (remaining > 0) {
val (days, hours) = formatMuteRemainingTime(remaining)
if (days >= 1) {
stringResource(Res.string.mute_status_muted_for_days, days, hours)
} else {
stringResource(Res.string.mute_status_muted_for_hours, hours)
}
} else {
stringResource(Res.string.mute_status_unmuted)
}
}
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
else -> stringResource(Res.string.mute_status_unmuted)
}
Text(
text = stringResource(Res.string.currently) + " " + statusText,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
muteOptions.forEachIndexed { index, (stringRes, _) ->
val isSelected = index == selectedOptionIndex
val text = stringResource(stringRes)
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
Text(text = text, modifier = Modifier.padding(start = 8.dp))
}
}
}
},
)
}
@Composable
private fun DeleteConfirmationDialog(
selectedCount: Int, // Number of items to be deleted
onDismiss: () -> Unit,
onConfirm: () -> Unit, // Lambda to handle the delete action
) {
val deleteMessage =
pluralStringResource(
Res.plurals.delete_messages,
selectedCount,
selectedCount, // Pass the count as a format argument
)
MeshtasticTextDialog(
titleRes = Res.string.are_you_sure,
message = deleteMessage,
confirmTextRes = Res.string.delete,
onConfirm = {
onConfirm()
onDismiss() // Dismiss the dialog after confirming
},
onDismiss = onDismiss,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionToolbar(
selectedCount: Int,
onCloseSelection: () -> Unit,
onMuteSelected: () -> Unit,
onDeleteSelected: () -> Unit,
onSelectAll: () -> Unit,
isAllMuted: Boolean,
) {
TopAppBar(
title = { Text(text = "$selectedCount") },
navigationIcon = {
IconButton(onClick = onCloseSelection) {
Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close_selection))
}
},
actions = {
IconButton(onClick = onMuteSelected) {
Icon(
imageVector =
if (isAllMuted) {
MeshtasticIcons.VolumeUpTwoTone
} else {
MeshtasticIcons.VolumeMuteTwoTone
},
contentDescription =
if (isAllMuted) {
"Unmute selected"
} else {
"Mute selected"
},
)
}
IconButton(onClick = onDeleteSelected) {
Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete_selection))
}
IconButton(onClick = onSelectAll) {
Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select_all))
}
},
)
}
@Composable
private fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
channels: ChannelSet? = null,
) {
val haptic = LocalHapticFeedback.current
Box(modifier = modifier.fillMaxSize()) {
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
ContactListContentInternal(
contacts = contacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
channels = channels,
haptic = haptic,
)
}
}
}
@Composable
private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: ChannelSet?,
haptic: HapticFeedback,
modifier: Modifier = Modifier,
) {
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListAppendLoadingItem(contacts)
}
}
private fun LazyListScope.contactListPlaceholdersItems(
placeholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptic: HapticFeedback,
) {
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
val contact = placeholders[index]
ContactItem(
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
private fun LazyListScope.contactListPagedItems(
contacts: LazyPagingItems<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptic: HapticFeedback,
) {
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
contacts[index]?.let { contact ->
ContactItem(
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
}
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
if (contacts.loadState.append is LoadState.Loading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
@Composable
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
channelPlaceholders.filter { it.contactKey !in pagedKeys }
}

View file

@ -1,221 +0,0 @@
/*
* 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 com.geeksville.mesh.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.geeksville.mesh.model.Contact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.getShortDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_name
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
import kotlin.collections.map as collectionsMap
@HiltViewModel
class ContactsViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
) : ViewModel() {
val ourNodeInfo = nodeRepository.ourNodeInfo
val connectionState = serviceRepository.connectionState
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
// Combine node info and myId to reduce argument count in subsequent combines
private val identityFlow: Flow<Pair<MyNodeEntity?, String?>> =
combine(nodeRepository.myNodeInfo, nodeRepository.myId) { info, id -> Pair(info, id) }
/**
* Non-paginated contact list.
*
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
* uses [contactListPaged] instead for better performance with large contact lists.
*
* @see contactListPaged for the paginated version used in ContactsScreen
*/
val contactList =
combine(identityFlow, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) {
identity,
contacts,
channelSet,
settings,
->
val (myNodeInfo, myId) = identity
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
val placeholder =
(0 until channelSet.settings.size).associate { ch ->
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
}
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.short_name ?: ""
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.long_name ?: ""
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) "${data.channel}" else shortName,
longName = longName,
lastMessageTime = getShortDate(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
.stateInWhileSubscribed(initialValue = emptyList())
val contactListPaged: Flow<PagingData<Contact>> =
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
val (myNodeInfo, myId) = identity
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
}
.flatMapLatest { params ->
val myNodeNum = params.myNodeNum
val channelSet = params.channelSet
val settings = params.settings
val myId = params.myId
packetRepository.getContactsPaged().map { pagingData ->
pagingData.map { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.short_name ?: ""
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.long_name ?: ""
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) "${data.channel}" else shortName,
longName = longName,
lastMessageTime = getShortDate(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
}
.cachedIn(viewModelScope)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
fun setMuteUntil(contacts: List<String>, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
fun getContactSettings() = packetRepository.getContactSettings()
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
/**
* Get the total message count for a list of contact keys. This queries the repository directly, so it works even if
* contacts aren't loaded in the paged list.
*/
suspend fun getTotalMessageCount(contactKeys: List<String>): Int = if (contactKeys.isEmpty()) {
0
} else {
contactKeys.sumOf { contactKey -> packetRepository.getMessageCount(contactKey) }
}
private fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
private data class ContactsPagedParams(
val myNodeNum: Int?,
val channelSet: ChannelSet,
val settings: Map<String, ContactSettings>,
val myId: String?,
)
}

View file

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

View file

@ -1,131 +0,0 @@
/*
* 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 com.geeksville.mesh.ui.sharing
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.Contact
import com.geeksville.mesh.ui.contact.ContactItem
import com.geeksville.mesh.ui.contact.ContactsViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.share
import org.meshtastic.core.resources.share_to
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)
}
@Composable
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
var selectedContact by remember { mutableStateOf("") }
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.share_to),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(contacts, key = { index, contact -> "${contact.contactKey}#$index" }) { _, contact ->
val selected = contact.contactKey == selectedContact
ContactItem(
contact = contact,
selected = selected,
onClick = { selectedContact = contact.contactKey },
)
}
}
Button(
onClick = { onConfirm(selectedContact) },
modifier = Modifier.fillMaxWidth().padding(24.dp),
enabled = selectedContact.isNotEmpty(),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(Res.string.share),
)
}
}
}
}
@PreviewScreenSizes
@Composable
private fun ShareScreenPreview() {
AppTheme {
ShareScreen(
contacts =
listOf(
Contact(
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
lastMessageTime = "3 minutes ago",
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
),
),
onConfirm = {},
onNavigateUp = {},
)
}
}

View file

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

View file

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