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

@ -23,10 +23,10 @@ import android.content.Context
import kotlinx.coroutines.runBlocking
import no.nordicsemi.android.dfu.DfuBaseService
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.model.BuildConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_channel_description
import org.meshtastic.core.resources.firmware_update_channel_name
import org.meshtastic.core.model.util.isDebug as isDebugFlag
class FirmwareDfuService : DfuBaseService() {
override fun onCreate() {
@ -57,5 +57,5 @@ class FirmwareDfuService : DfuBaseService() {
null
}
override fun isDebug(): Boolean = BuildConfig.DEBUG
override fun isDebug(): Boolean = isDebugFlag
}

View file

@ -580,7 +580,8 @@ private fun DeviceInfoCard(
val currentVersionString =
stringResource(
Res.string.firmware_update_currently_installed,
currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release),
currentFirmwareVersion?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.firmware_update_unknown_release),
)
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
Spacer(Modifier.height(4.dp))
@ -825,7 +826,7 @@ private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) {
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = spacedBy(16.dp)) {
OutlinedButton(onClick = onRetry) {
Icon(MeshtasticIcons.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.map
import android.Manifest
import android.graphics.Paint
import android.text.format.DateUtils
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -85,6 +84,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.hasGps
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
@ -496,12 +496,7 @@ fun MapView(
val pt = waypoint.data.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
val time =
DateUtils.formatDateTime(
context,
waypoint.received_time,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
val time = DateFormatter.formatDateTime(waypoint.received_time)
val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
val now = nowMillis
@ -510,14 +505,7 @@ fun MapView(
when {
(pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
else ->
DateUtils.getRelativeTimeSpanString(
expireTimeMillis,
now,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
)
.toString()
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
@ -719,6 +707,7 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
@Suppress("MagicNumber")
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -805,6 +794,7 @@ fun MapView(
text = stringResource(Res.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
@Suppress("MagicNumber")
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
@ -1075,7 +1065,8 @@ private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
private fun Double.toRad(): Double = Math.toRadians(this)
@Suppress("MagicNumber")
private fun Double.toRad(): Double = this * Math.PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
@ -1116,6 +1107,8 @@ private fun offsetPolyline(
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
@Suppress("MagicNumber")
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}

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,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.model
import android.content.res.Resources
import android.util.Log
import co.touchlab.kermit.Logger
import org.osmdroid.api.IMapView
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
@ -78,7 +77,7 @@ open class NOAAWmsTileSource(
private var forceHttp = false
init {
Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" }
layer = layername
this.version = version
this.srs = srs
@ -165,7 +164,7 @@ open class NOAAWmsTileSource(
sb.append(bbox[minY]).append(",")
sb.append(bbox[maxX]).append(",")
sb.append(bbox[maxY])
Log.i(IMapView.LOGTAG, sb.toString())
Logger.withTag(IMapView.LOGTAG).i { sb.toString() }
return sb.toString()
}

View file

@ -6,9 +6,9 @@
<path
android:fillColor="#3388ff"
android:strokeWidth="1.25"
android:strokeColor="@android:color/white"
android:strokeColor="#ffffffff"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
<path
android:fillColor="@android:color/transparent"
android:fillColor="#00000000"
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
</vector>
</vector>

View file

@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="2.0"
android:strokeColor="@android:color/white" />
</vector>
android:strokeColor="#ffffffff" />
</vector>

View file

@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
android:strokeWidth="1.5"
android:strokeColor="@android:color/white" />
</vector>
android:strokeColor="#ffffffff" />
</vector>

View file

@ -16,23 +16,6 @@
*/
import com.android.build.api.dsl.LibraryExtension
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
@ -42,19 +25,29 @@ plugins {
configure<LibraryExtension> { namespace = "org.meshtastic.feature.messaging" }
dependencies {
implementation(projects.core.analytics)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.kermit)

View file

@ -0,0 +1,182 @@
/*
* 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.feature.messaging.ui.contact
import android.net.Uri
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
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "LongParameterList")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
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) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = onHandleScannedUri,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
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

@ -0,0 +1,239 @@
/*
* 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.feature.messaging.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 org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.Contact
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 =
contact.nodeColors?.let {
AssistChipDefaults.assistChipColors(labelColor = Color(it.first), containerColor = Color(it.second))
} ?: 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?.let { DateFormatter.formatShortDate(it) }.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 = 0L,
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 = 1000L,
),
)
AppTheme { Column { contactsList.forEach { contact -> ContactItem(contact = contact, selected = false) } } }
}

View file

@ -0,0 +1,616 @@
/*
* 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.feature.messaging.ui.contact
import android.net.Uri
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.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.Contact
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 org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel<ContactsViewModel>(),
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 = null,
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 } }
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { onClearRequestChannelUrl() }) }
// 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 ->
onHandleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onShareChannels = onNavigateToShare,
onDismissSharedContact = { onClearSharedContactRequested() },
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

@ -0,0 +1,216 @@
/*
* 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.feature.messaging.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
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.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.Contact
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
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 userId = if (fromLocal) data.to else data.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val shortName = user.short_name
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
user.long_name
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
lastMessageTime = if (data.time != 0L) data.time else null,
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 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 userId = if (fromLocal) data.to else data.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val shortName = user.short_name
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
user.long_name
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
lastMessageTime = if (data.time != 0L) data.time else null,
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 data class ContactsPagedParams(
val myNodeNum: Int?,
val channelSet: ChannelSet,
val settings: Map<String, ContactSettings>,
val myId: String?,
)
}

View file

@ -0,0 +1,131 @@
/*
* 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.feature.messaging.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 org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Contact
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
import org.meshtastic.feature.messaging.ui.contact.ContactItem
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@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 = 0L,
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
),
),
onConfirm = {},
onNavigateUp = {},
)
}
}

View file

@ -29,12 +29,12 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.Config

View file

@ -40,8 +40,8 @@ import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.metrics
import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@ -39,11 +38,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
@ -85,8 +84,6 @@ fun NeighborInfoLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
@ -128,12 +125,7 @@ fun NeighborInfoLogScreen(
}
}
val time =
DateUtils.formatDateTime(
context,
log.received_date,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
val time = DateFormatter.formatDateTime(log.received_date)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.metrics
import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@ -40,7 +39,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.PreviewLightDark
@ -49,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
@ -106,7 +105,6 @@ fun TracerouteLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
@ -151,12 +149,7 @@ fun TracerouteLogScreen(
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
val time =
DateUtils.formatDateTime(
context,
log.received_date,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
val time = DateFormatter.formatDateTime(log.received_date)
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
@ -278,12 +271,7 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {
val time =
DateUtils.formatDateTime(
LocalContext.current,
nowMillis,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
val time = DateFormatter.formatDateTime(nowMillis)
AppTheme {
MetricLogItem(
icon = MeshtasticIcons.Group,

View file

@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.extractWifiCredentials
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.model.util.handleMeshtasticUri
import org.meshtastic.core.model.util.toCommonUri
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
@ -120,7 +121,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
if (contents != null) {
val handled =
handleMeshtasticUri(
uri = contents.toUri(),
uri = contents.toUri().toCommonUri(),
onChannel = {}, // No-op, not supported in network config
onContact = {}, // No-op, not supported in network config
)