Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ keystore.properties

/build-logic/convention/build/*
/build-logic/build/

# Claude Code
.claude/
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ interface BuildConfigProvider {
val absoluteMinFwVersion: String
val minFwVersion: String
}

const val DEFAULT_MAP_URL = "geo:0,0?q=%LAT,%LON(%SNAME)"
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.util.LinkifyCompat
import org.meshtastic.core.ui.theme.HyperlinkBlue
import java.util.regex.Pattern

private val DefaultTextLinkStyles =
TextLinkStyles(style = SpanStyle(color = HyperlinkBlue, textDecoration = TextDecoration.Underline))
Expand All @@ -55,26 +56,41 @@ fun AutoLinkText(

private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also {
LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS)
// Add geo: URI pattern for location links with optional query params and/or label in parentheses
val geoPattern = Pattern.compile("geo:[-+]?\\d*\\.?\\d+,[-+]?\\d*\\.?\\d+(?:[?\\(][^\\s]*)?")
Linkify.addLinks(it, geoPattern, "geo:")
}

private fun Spannable.toAnnotatedString(linkStyles: TextLinkStyles): AnnotatedString = buildAnnotatedString {
val spannable = this@toAnnotatedString
var lastEnd = 0
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
append(spannable.subSequence(lastEnd, start))
when (span) {
is URLSpan ->
withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) {
append(spannable.subSequence(start, end))
}

else -> append(spannable.subSequence(start, end))
// Get only URLSpan objects and sort them by start position
val urlSpans =
spannable
.getSpans(0, spannable.length, URLSpan::class.java)
.map { span -> Triple(span, spannable.getSpanStart(span), spannable.getSpanEnd(span)) }
.sortedBy { it.second }

urlSpans.forEach { (span, start, end) ->
// Skip overlapping spans
if (start < lastEnd) return@forEach

// Append text before the link
if (start > lastEnd) {
append(spannable.subSequence(lastEnd, start))
}

// Append the link
withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) { append(spannable.subSequence(start, end)) }

lastEnd = end
}
append(spannable.subSequence(lastEnd, spannable.length))

// Append remaining text
if (lastEnd < spannable.length) {
append(spannable.subSequence(lastEnd, spannable.length))
}
}

@Preview(showBackground = true)
Expand Down
1 change: 1 addition & 0 deletions feature/messaging/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ plugins {
android { namespace = "org.meshtastic.feature.messaging" }

dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
Expand Down
3 changes: 2 additions & 1 deletion feature/messaging/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
<ID>LambdaParameterEventTrailing:MessageList.kt$onReply</ID>
<ID>LambdaParameterEventTrailing:QuickChat.kt$onNavigateUp</ID>
<ID>LambdaParameterInRestartableEffect:MessageList.kt$onUnreadChanged</ID>
<ID>LongParameterList:MessageViewModel.kt$MessageViewModel$( private val nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, quickChatActionRepository: QuickChatActionRepository, private val serviceRepository: ServiceRepository, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val meshServiceNotifications: MeshServiceNotifications, )</ID>
<ID>LongParameterList:MessageViewModel.kt$MessageViewModel$( private val nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, private val quickChatActionRepository: QuickChatActionRepository, private val serviceRepository: ServiceRepository, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val meshServiceNotifications: MeshServiceNotifications, buildConfigProvider: org.meshtastic.core.common.BuildConfigProvider, )</ID>
<ID>MagicNumber:Message.kt$1e-7</ID>
<ID>ModifierMissing:Message.kt$MessageScreen</ID>
<ID>ModifierNotUsedAtRoot:QuickChat.kt$modifier = modifier.fillMaxSize().padding(innerPadding)</ID>
<ID>MutableStateParam:MessageList.kt$selectedIds</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.MeshProtos
import java.nio.charset.StandardCharsets

private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
Expand Down Expand Up @@ -299,10 +300,13 @@ fun MessageScreen(
QuickChatRow(
enabled = connectionState.isConnected(),
actions = quickChatActions,
userPosition = ourNode?.validPosition,
onClick = { action ->
handleQuickChatAction(
action = action,
messageInputState = messageInputState,
userPosition = ourNode?.validPosition,
ourNode = ourNode,
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
)
},
Expand Down Expand Up @@ -422,25 +426,49 @@ private fun String.ellipsize(maxLength: Int): String = if (length > maxLength) "
*
* @param action The [QuickChatAction] to handle.
* @param messageInputState The [TextFieldState] of the message input field.
* @param userPosition Current user position (lat/lng), if available.
* @param onSendMessage Lambda to call when a message needs to be sent.
*/
private fun handleQuickChatAction(
action: QuickChatAction,
messageInputState: TextFieldState,
userPosition: MeshProtos.Position?,
ourNode: Node?,
onSendMessage: (String) -> Unit,
) {
val processedMessage =
if (
action.message.contains("%LAT", ignoreCase = true) ||
action.message.contains("%LON", ignoreCase = true) ||
action.message.contains("%SNAME", ignoreCase = true)
) {
var result = action.message
userPosition?.let {
val latitude = "%.7f".format(it.latitudeI * 1e-7)
val longitude = "%.7f".format(it.longitudeI * 1e-7)
result =
result.replace("%LAT", latitude, ignoreCase = true).replace("%LON", longitude, ignoreCase = true)
}
ourNode?.user?.shortName?.let { shortName ->
result = result.replace("%SNAME", shortName, ignoreCase = true)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're going to have an issue with shortnames with a space, among other cases.

The space will break your new regex, and other items like shortName = "<')<" or ">))>" (my poor attempt at a four char ascii art fish), will break the Linkify URL parsing.

You're going to need URI encoding for the label:

Suggested change
result = result.replace("%SNAME", shortName, ignoreCase = true)
result = result.replace("%SNAME", Uri.encode(shortName), ignoreCase = true)

But this URI-encodes all uses of %SNAME.

Some options:

  • Have a %SNAMEENC, which URI encodes
  • Falling back to a %GEO token which directly get replaced with the full "geo:(${latitude),${longitude}(${Uri.encode(label)})"
    • Granted, I like the flexibility of independent replacement tokens, as I want to emit "geo:47.62148,-122.34814(FriendXYZ+@+2025-11-09+16:40)", which would be from hypothetical future tokens of "geo:%LAT,%LON(%LNAMEENC+@+$DATE+$TIME)" (timestamp is so folks don't confuse old map pins)
  • Handeling on the receiver side -- from geo:47.62148,-122.34814, auto-append a label of who+when, if a label doesn't exist, before sending to Linkify

Copy link
Author

@suteny0r suteny0r Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I applied your URI encoding change. (I can't believe I didn't think about that before)
I implemented %LNAME (but I did not add it to the template, I like the shorter label on maps)
The pin created is ephemeral (unlike waypoint pins) so adding date/timestamps seems unnecessary, as they only exist for the lifetime of the view session, and the messages they appear in already have a timestamp.

In the context of quick chat messages, it seems redundant to want to include an unencode version of the node name in the message, because every message will already have that (sender).

What sort of failure/fallback cases are you thinking about for the simpler %GEO message use?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might I suggest %F0%9F%90%A0 for your fish? :)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What sort of failure/fallback cases are you thinking about for the simpler %GEO message use?

No failure cases, just mentioning that you can use a %GEO token to produce the full message, and you wouldn't have to worry about when to encode %SNAME.

In your updated code, you're always encoding the %SNAME & the now added %LNAME too. This has the issue of URI encoding when not needed, like with a quick message of "Test message seen by %LNAME in SOMA"; sending %SNAME or %LNAME is useful as most nodes in my list don't have a name, so I assume others likewise don't know my node's name (even more likely for new nodes):

Web client viewAndroid client view
Screenshot 2025-11-13 at 07 51 39No names for most image
I'm sure there's better use cases for node names that I'm not considering.

The pin created is ephemeral (unlike waypoint pins) so adding date/timestamps seems unnecessary, as they only exist for the lifetime of the view session, and the messages they appear in already have a timestamp.

The pins can be non-ephemeral once in the map apps, directly so if bookmarked, seen in search history, or used as a routing destination. I certainly have a ton of old bookmarked temporary locations.

Message timestamps are lost in store-and-forward; so if you're disconnected from the mesh when someone sends their location, you'll get the stored message automatically when you reconnect but it will have the current timestamp displayed on an hours old message, implying the sender is at that location now. Granted that should be fixed in store-and-forward.

Might I suggest %F0%9F%90%A0 for your fish? :)

I could make a case for spiky boi %F0%9F%90%A1.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get it now. People may want to use the variable expansion in quick chat messages other than pin drops. Now I see why the *ENC variants would be useful.

I started out with this being a quick chat mod. currently the variable expansions only happen when quick chat messages are triggered. Should I expand it so that any manually typed messages in the message window can use variable expansions? I don't like making changes to areas outside of the scope of the enhancement, so I'm inclined not to do that.

I noticed that the other variables from your example, $date and $time don't actually work. Were they hypothetical or am I using them wrong?

I see now that it could be useful to ALSO provide a single %GEO expansion with the simple, non labeled version of the geo: URI

I'll go add that back in %GEO for single term expansion, in addition to the current set.

Do you think the URI encoding overhead is significant enough to warrant the complexity of only doing it when the variable is used?

Copy link

@justinormont justinormont Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started out with this being a quick chat mod. currently the variable expansions only happen when quick chat messages are triggered. Should I expand it so that any manually typed messages in the message window can use variable expansions? I don't like making changes to areas outside of the scope of the enhancement, so I'm inclined not to do that.

I don't have a feel for the Meshtastic repos, but it's likely easier to put in a MVP with a constrained set of tokens, like (%LAT, %LON) or %GEO, touching a minimal area of code, in the first PR. Letting users test the feature, and setting the basis for a discussion on what is useful and a followup of adding those tokens and further functionality.

Couple examples as precedent of a thought-though replacement token & operator scheme:

Takeaways from looking at replacement token implementations:

  • All had a start AND end deliminator e.g #{TOKEN}# (Azure Pipelines), $TOKEN$ (splunk), *|FNAME|* (mailchimp)
  • Most use namespaces, e.g. $server.serverName$ (splunk), *|USER:COMPANY|* (Mailchimp)
  • Most have operators built-into the token to do things like lowercase, url encode, etc:
    • Terse: Most are short and combined w/ the token directly $token_name|u$ (splunk), and *|URL:FNAME|* (Mailchimp)
    • Verbose: ArcGIS has vary verbose style like <dyn type="mapFrame" name="MapFrameName" property="lowerLeft.x" units="dd.deg" decimalPlaces="2" showDirections="True"/> to get the longitude of the left side of the map rounded to two decimal places "122.31"
    • None that I saw had free-form scripting language, like ROUND($LAT$, 2)) for its operators

I might recommend:

E.g. $POSITION:LATITUDE$ and $USER:LONGNAME|U$ for a URL encoded long name. Though something more terse is nice too, like $LNAME|U$.

I noticed that the other variables from your example, $date and $time don't actually work. Were they hypothetical or am I using them wrong?

Hypothetical future tokens.

Do you think the URI encoding overhead is significant enough to warrant the complexity of only doing it when the variable is used?

Not sure what you're asking.

}
result
} else {
action.message
}

when (action.mode) {
QuickChatAction.Mode.Append -> {
val originalText = messageInputState.text.toString()
// Avoid appending if the exact message is already present (simple check)
if (!originalText.contains(action.message)) {
if (!originalText.contains(processedMessage)) {
val newText =
buildString {
append(originalText)
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
append(' ')
}
append(action.message)
append(processedMessage)
}
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
messageInputState.setTextAndPlaceCursorAtEnd(newText)
Expand All @@ -449,7 +477,7 @@ private fun handleQuickChatAction(

QuickChatAction.Mode.Instant -> {
// Byte limit for 'Send' mode messages is handled by the backend/transport layer.
onSendMessage(action.message)
onSendMessage(processedMessage)
}
}
}
Expand Down Expand Up @@ -683,13 +711,15 @@ private fun OverFlowMenu(
*
* @param enabled Whether the buttons should be enabled.
* @param actions The list of [QuickChatAction]s to display.
* @param userPosition Current user position, used to determine if location-based actions can be enabled.
* @param onClick Callback when a quick chat button is clicked.
*/
@Composable
private fun QuickChatRow(
modifier: Modifier = Modifier,
enabled: Boolean,
actions: List<QuickChatAction>,
userPosition: MeshProtos.Position?,
onClick: (QuickChatAction) -> Unit,
) {
val alertActionMessage = stringResource(R.string.alert_bell_text)
Expand All @@ -707,8 +737,11 @@ private fun QuickChatRow(
val allActions = remember(alertAction, actions) { listOf(alertAction) + actions }

LazyRow(modifier = modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
items(allActions, key = { it.uuid }) { action ->
Button(onClick = { onClick(action) }, enabled = enabled) { Text(text = action.name) }
items(allActions, key = { it.position }) { action ->
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we changing this key?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key was changed from uuid to position to fix a crash: Key "0" was already used.

Both the alert bell action and the default location pin action are created with uuid=0L (the default
value). When both are present in the LazyRow, this causes a key collision that crashes the app.

The position field is guaranteed unique:

Bell action: position = -1
Location pin action: position = -2 (or could be 0 if it's user-editable)
User-created actions: position = 0, 1, 2...
This ensures each item in the LazyRow has a unique, stable key for proper Compose recomposition.

val requiresPosition =
action.message.contains("%LAT", ignoreCase = true) || action.message.contains("%LON", ignoreCase = true)
val isEnabled = enabled && (!requiresPosition || userPosition != null)
Button(onClick = { onClick(action) }, enabled = isEnabled) { Text(text = action.name) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.common.DEFAULT_MAP_URL
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.QuickChatActionRepository
Expand All @@ -50,12 +51,13 @@ import javax.inject.Inject
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"

@HiltViewModel
@Suppress("LongParameterList")
class MessageViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
private val quickChatActionRepository: QuickChatActionRepository,
private val serviceRepository: ServiceRepository,
private val packetRepository: PacketRepository,
private val uiPrefs: UiPrefs,
Expand All @@ -77,6 +79,27 @@ constructor(

val quickChatActions = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())

init {
viewModelScope.launch(Dispatchers.IO) {
val actions = quickChatActionRepository.getAllActions()
var isEmpty = true
actions.collect { list ->
if (isEmpty && list.isEmpty()) {
// No actions exist, create default location pin with label
quickChatActionRepository.upsert(
org.meshtastic.core.database.entity.QuickChatAction(
name = "📍",
message = DEFAULT_MAP_URL,
mode = org.meshtastic.core.database.entity.QuickChatAction.Mode.Append,
position = 0,
),
)
}
isEmpty = false
}
}
}

private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val messagesForContactKey: StateFlow<List<Message>> =
contactKeyForMessages
Expand Down