Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -20,6 +20,7 @@
package org.meshtastic.feature.messaging

import android.content.ClipData
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -106,6 +107,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 +301,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 +427,55 @@ 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 hasVariables =
action.message.contains("%LAT", ignoreCase = true) ||
action.message.contains("%LON", ignoreCase = true) ||
action.message.contains("%SNAME", ignoreCase = true) ||
action.message.contains("%LNAME", ignoreCase = true)

val processedMessage =
if (hasVariables) {
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", Uri.encode(shortName), ignoreCase = true)
}
ourNode?.user?.longName?.let { longName ->
result = result.replace("%LNAME", Uri.encode(longName), ignoreCase = true)
}
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)
append(' ') // Always add trailing space for link separation
}
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
messageInputState.setTextAndPlaceCursorAtEnd(newText)
Expand All @@ -449,7 +484,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 +718,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 +744,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