Modularize messaging code (#3435)

This commit is contained in:
Phil Oliver 2025-10-12 13:07:03 -04:00 committed by GitHub
parent cd1a54f506
commit 886e9cfede
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 297 additions and 143 deletions

View file

@ -17,39 +17,40 @@
package com.geeksville.mesh
import android.app.Application
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.MeshServiceNotificationsImpl
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
object ApplicationModule {
interface ApplicationModule {
@Provides fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
@Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
@Provides
fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle = processLifecycleOwner.lifecycle
companion object {
@Provides fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
@Provides
fun providesMeshServiceNotifications(application: Application): MeshServiceNotifications =
MeshServiceNotifications(application)
@Provides
fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle = processLifecycleOwner.lifecycle
@Singleton
@Provides
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = BuildConfig.DEBUG
override val applicationId: String = BuildConfig.APPLICATION_ID
override val versionCode: Int = BuildConfig.VERSION_CODE
override val versionName: String = BuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
@Singleton
@Provides
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = BuildConfig.DEBUG
override val applicationId: String = BuildConfig.APPLICATION_ID
override val versionCode: Int = BuildConfig.VERSION_CODE
override val versionName: String = BuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
}
}
}

View file

@ -31,10 +31,7 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.ui.sharing.toSharedContact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@ -53,17 +50,17 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ConfigProtos.Config
@ -130,7 +127,6 @@ constructor(
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
private val quickChatActionRepository: QuickChatActionRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
@ -217,12 +213,6 @@ constructor(
.map { it.coerceAtLeast(0) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), 0)
val quickChatActions
get() =
quickChatActionRepository
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
@ -337,20 +327,6 @@ constructor(
}
}
fun addQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
fun deleteQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
fun updateActionPositions(actions: List<QuickChatAction>) {
viewModelScope.launch(Dispatchers.IO) {
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
}
}
val tracerouteResponse: LiveData<String?>
get() = serviceRepository.tracerouteResponse.asLiveData()

View file

@ -24,13 +24,13 @@ import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.contact.ContactsScreen
import com.geeksville.mesh.ui.message.MessageScreen
import com.geeksville.mesh.ui.message.QuickChatScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.QuickChatScreen
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController) {

View file

@ -81,6 +81,8 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
@ -354,7 +356,7 @@ class MeshService : Service() {
try {
ServiceCompat.startForeground(
this,
MeshServiceNotifications.SERVICE_NOTIFY_ID,
SERVICE_NOTIFY_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (hasLocationPermission()) {

View file

@ -37,13 +37,17 @@ import androidx.core.net.toUri
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R.raw
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import dagger.hilt.android.qualifiers.ApplicationContext
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.strings.R
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.LocalStats
import javax.inject.Inject
/**
* Manages the creation and display of all app notifications.
@ -52,14 +56,14 @@ import org.meshtastic.proto.TelemetryProtos.LocalStats
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions")
class MeshServiceNotifications(private val context: Context) {
class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext private val context: Context) :
MeshServiceNotifications {
private val notificationManager = context.getSystemService<NotificationManager>()!!
companion object {
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
const val MAX_BATTERY_LEVEL = 100
const val SERVICE_NOTIFY_ID = 101
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
}
@ -139,7 +143,7 @@ class MeshServiceNotifications(private val context: Context) {
}
}
fun clearNotifications() {
override fun clearNotifications() {
notificationManager.cancelAll()
}
@ -147,7 +151,7 @@ class MeshServiceNotifications(private val context: Context) {
* Creates all necessary notification channels on devices running Android O or newer. This should be called once
* when the service is created.
*/
fun initChannels() {
override fun initChannels() {
NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
}
@ -212,9 +216,9 @@ class MeshServiceNotifications(private val context: Context) {
var cachedMessage: String? = null
// region Public Notification Methods
fun updateServiceStateNotification(
override fun updateServiceStateNotification(
summaryString: String?,
telemetry: TelemetryProtos.Telemetry? = cachedTelemetry,
telemetry: TelemetryProtos.Telemetry?,
): Notification {
val hasLocalStats = telemetry?.hasLocalStats() == true
val hasDeviceMetrics = telemetry?.hasDeviceMetrics() == true
@ -249,39 +253,39 @@ class MeshServiceNotifications(private val context: Context) {
return notification
}
fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) {
override fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) {
val notification = createMessageNotification(contactKey, name, message, isBroadcast)
// Use a consistent, unique ID for each message conversation.
notificationManager.notify(contactKey.hashCode(), notification)
}
fun showAlertNotification(contactKey: String, name: String, alert: String) {
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
val notification = createAlertNotification(contactKey, name, alert)
// Use a consistent, unique ID for each alert source.
notificationManager.notify(name.hashCode(), notification)
}
fun showNewNodeSeenNotification(node: NodeEntity) {
override fun showNewNodeSeenNotification(node: NodeEntity) {
val notification = createNewNodeSeenNotification(node.user.shortName, node.user.longName)
notificationManager.notify(node.num, notification)
}
fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
val notification = createLowBatteryNotification(node, isRemote)
notificationManager.notify(node.num, notification)
}
fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {
override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {
val notification =
createClientNotification(context.getString(R.string.client_notification), clientNotification.message)
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
fun clearClientNotification(notification: MeshProtos.ClientNotification) =
override fun clearClientNotification(notification: MeshProtos.ClientNotification) =
notificationManager.cancel(notification.toString().hashCode())
// endregion

View file

@ -22,6 +22,7 @@ import androidx.core.app.RemoteInput
import dagger.hilt.android.AndroidEntryPoint
import jakarta.inject.Inject
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
/**
@ -58,7 +59,7 @@ class ReplyReceiver : BroadcastReceiver() {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
sendMessage(message, contactKey)
MeshServiceNotifications(context).cancelMessageNotification(contactKey)
MeshServiceNotificationsImpl(context).cancelMessageNotification(contactKey)
}
}
}

View file

@ -97,7 +97,6 @@ import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
import com.geeksville.mesh.ui.metrics.annotateTraceroute
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -119,6 +118,7 @@ import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.icon.Settings
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.feature.settings.navigation.settingsGraph

View file

@ -1,817 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package com.geeksville.mesh.ui.message
import android.content.ClipData
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.SpeakerNotes
import androidx.compose.material.icons.filled.SpeakerNotesOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.component.NodeKeyStatusIcon
import org.meshtastic.proto.AppOnlyProtos
import java.nio.charset.StandardCharsets
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
private const val SNIPPET_CHARACTER_LIMIT = 50
private const val ROUNDED_CORNER_PERCENT = 100
/**
* The main screen for displaying and sending messages to a contact or channel.
*
* @param contactKey A unique key identifying the contact or channel.
* @param message An optional message to pre-fill in the input field.
* @param viewModel The [MessageViewModel] instance for handling business logic and state.
* @param navigateToMessages Callback to navigate to a different message thread.
* @param navigateToNodeDetails Callback to navigate to a node's detail screen.
* @param onNavigateBack Callback to navigate back from this screen.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod") // Due to multiple states and event handling
@Composable
internal fun MessageScreen(
contactKey: String,
message: String,
viewModel: MessageViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
navigateToQuickChatOptions: () -> Unit,
onNavigateBack: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
// UI State managed within this Composable
var replyingToPacketId by rememberSaveable { mutableStateOf<Int?>(null) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message)
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
// Derived state, memoized for performance
val channelInfo =
remember(contactKey, channels) {
val index = contactKey.firstOrNull()?.digitToIntOrNull()
val id = contactKey.substring(1)
val name = index?.let { channels.getChannel(it)?.name } // channels can be null initially
Triple(index, id, name)
}
val (channelIndex, nodeId, rawChannelName) = channelInfo
val unknownChannelText = stringResource(id = R.string.unknown_channel)
val channelName = rawChannelName ?: unknownChannelText
val title =
remember(nodeId, channelName, viewModel) {
when (nodeId) {
DataPacket.ID_BROADCAST -> channelName
else -> viewModel.getUser(nodeId).longName
}
}
val isMismatchKey =
remember(channelIndex, nodeId, viewModel) {
channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey
}
val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } }
val listState =
rememberLazyListState(
initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) },
)
val onEvent: (MessageScreenEvent) -> Unit =
remember(viewModel, contactKey, messageInputState, ourNode) {
{ event ->
when (event) {
is MessageScreenEvent.SendMessage -> {
viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId)
if (event.replyingToPacketId != null) replyingToPacketId = null
messageInputState.clearText()
}
is MessageScreenEvent.SendReaction ->
viewModel.sendReaction(event.emoji, event.messageId, contactKey)
is MessageScreenEvent.DeleteMessages -> {
viewModel.deleteMessages(event.ids)
selectedMessageIds.value = emptySet()
showDeleteDialog = false
}
is MessageScreenEvent.ClearUnreadCount ->
viewModel.clearUnreadCount(contactKey, event.lastReadMessageId)
is MessageScreenEvent.NodeDetails -> navigateToNodeDetails(event.node.num)
is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title)
is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey)
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
selectedMessageIds.value = emptySet()
}
}
}
}
if (showDeleteDialog) {
DeleteMessageDialog(
count = selectedMessageIds.value.size,
onConfirm = { onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList())) },
onDismiss = { showDeleteDialog = false },
)
}
sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
if (inSelectionMode) {
ActionModeTopBar(
selectedCount = selectedMessageIds.value.size,
onAction = { action ->
when (action) {
MessageMenuAction.ClipboardCopy -> {
val copiedText =
messages
.filter { it.uuid in selectedMessageIds.value }
.joinToString("\n") { it.text }
onEvent(MessageScreenEvent.CopyToClipboard(copiedText))
}
MessageMenuAction.Delete -> showDeleteDialog = true
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
MessageMenuAction.SelectAll -> {
selectedMessageIds.value =
if (selectedMessageIds.value.size == messages.size) {
emptySet()
} else {
messages.map { it.uuid }.toSet()
}
}
}
},
)
} else {
MessageTopBar(
title = title,
channelIndex = channelIndex,
mismatchKey = isMismatchKey,
onNavigateBack = { onEvent(MessageScreenEvent.NavigateBack) },
channels = channels,
channelIndexParam = channelIndex,
showQuickChat = showQuickChat,
onToggleQuickChat = viewModel::toggleShowQuickChat,
onNavigateToQuickChatOptions = navigateToQuickChatOptions,
)
}
},
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
Box(modifier = Modifier.weight(1f)) {
MessageList(
nodes = nodes,
ourNode = ourNode,
modifier = Modifier.fillMaxSize(),
listState = listState,
messages = messages,
selectedIds = selectedMessageIds,
onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
onDeleteMessages = { viewModel.deleteMessages(it) },
onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) },
contactKey = contactKey,
onReply = { message -> replyingToPacketId = message?.packetId },
onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) },
)
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
ScrollToBottomFab(coroutineScope, listState)
}
}
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
enabled = connectionState.isConnected(),
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
action = action,
messageInputState = messageInputState,
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
)
},
)
}
val originalMessage by
remember(replyingToPacketId, messages) {
derivedStateOf {
replyingToPacketId?.let { messages.firstOrNull { it.packetId == replyingToPacketId } }
}
}
ReplySnippet(
originalMessage = originalMessage,
onClearReply = { replyingToPacketId = null },
ourNode = ourNode,
)
MessageInput(
isEnabled = connectionState.isConnected(),
textFieldState = messageInputState,
onSendMessage = {
val messageText = messageInputState.text.toString().trim()
if (messageText.isNotEmpty()) {
onEvent(MessageScreenEvent.SendMessage(messageText, replyingToPacketId))
}
},
)
}
}
}
/**
* A FloatingActionButton that scrolls the message list to the bottom (most recent messages).
*
* @param coroutineScope The coroutine scope for launching the scroll animation.
* @param listState The [LazyListState] of the message list.
*/
@Composable
private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState) {
FloatingActionButton(
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
onClick = {
coroutineScope.launch {
// Assuming messages are ordered with the newest at index 0
listState.animateScrollToItem(0)
}
},
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = stringResource(id = R.string.scroll_to_bottom),
)
}
}
/**
* Displays a snippet of the message being replied to.
*
* @param originalMessage The message being replied to, or null if not replying.
* @param onClearReply Callback to clear the reply state.
* @param ourNode The current user's node information, to display "You" if replying to self.
*/
@Composable
private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ourNode: Node?) {
AnimatedVisibility(visible = originalMessage != null) {
originalMessage?.let { message ->
val isFromLocalUser = message.node.user.id == DataPacket.ID_LOCAL
val replyingToNodeUser = if (isFromLocalUser) ourNode?.user else message.node.user
val unknownUserText = stringResource(R.string.unknown)
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Reply,
contentDescription = stringResource(R.string.reply), // Decorative
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.replying_to, replyingToNodeUser?.shortName ?: unknownUserText),
style = MaterialTheme.typography.labelMedium,
)
Text(
modifier = Modifier.weight(1f),
text = message.text.ellipsize(SNIPPET_CHARACTER_LIMIT),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = onClearReply) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.cancel_reply), // Specific action
)
}
}
}
}
}
/**
* Ellipsizes a string if its length exceeds [maxLength].
*
* @param maxLength The maximum number of characters to display before adding "".
* @return The ellipsized string.
* @receiver The string to ellipsize.
*/
private fun String.ellipsize(maxLength: Int): String = if (length > maxLength) "${take(maxLength)}" else this
/**
* Handles a quick chat action, either appending its message to the input field or sending it directly.
*
* @param action The [QuickChatAction] to handle.
* @param messageInputState The [TextFieldState] of the message input field.
* @param onSendMessage Lambda to call when a message needs to be sent.
*/
private fun handleQuickChatAction(
action: QuickChatAction,
messageInputState: TextFieldState,
onSendMessage: (String) -> Unit,
) {
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)) {
val newText =
buildString {
append(originalText)
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
append(' ')
}
append(action.message)
}
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
messageInputState.setTextAndPlaceCursorAtEnd(newText)
}
}
QuickChatAction.Mode.Instant -> {
// Byte limit for 'Send' mode messages is handled by the backend/transport layer.
onSendMessage(action.message)
}
}
}
/**
* Truncates a string to ensure its UTF-8 byte representation does not exceed [maxBytes].
*
* This implementation iterates by characters and checks byte length to avoid splitting multi-byte characters.
*
* @param maxBytes The maximum allowed byte length.
* @return The truncated string, or the original string if it's within the byte limit.
* @receiver The string to limit.
*/
private fun String.limitBytes(maxBytes: Int): String {
val bytes = this.toByteArray(StandardCharsets.UTF_8)
if (bytes.size <= maxBytes) {
return this
}
var currentBytesSum = 0
var validCharCount = 0
for (charIndex in this.indices) {
val charToTest = this[charIndex]
val charBytes = charToTest.toString().toByteArray(StandardCharsets.UTF_8).size
if (currentBytesSum + charBytes > maxBytes) {
break
}
currentBytesSum += charBytes
validCharCount++
}
return this.substring(0, validCharCount)
}
/**
* A dialog confirming the deletion of messages.
*
* @param count The number of messages to be deleted.
* @param onConfirm Callback invoked when the user confirms the deletion.
* @param onDismiss Callback invoked when the dialog is dismissed.
*/
@Composable
private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, count, count)
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
title = { Text(stringResource(R.string.delete_messages_title)) },
text = { Text(text = deleteMessagesString) },
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
/** Actions available in the message selection mode's top bar. */
internal sealed class MessageMenuAction {
data object ClipboardCopy : MessageMenuAction()
data object Delete : MessageMenuAction()
data object Dismiss : MessageMenuAction()
data object SelectAll : MessageMenuAction()
}
/**
* The top app bar displayed when in message selection mode.
*
* @param selectedCount The number of currently selected messages.
* @param onAction Callback for when a menu action is triggered.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) = TopAppBar(
title = { Text(text = selectedCount.toString()) },
navigationIcon = {
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.clear_selection),
)
}
},
actions = {
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(id = R.string.copy))
}
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
}
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(id = R.string.select_all),
)
}
},
)
/**
* The default top app bar for the message screen.
*
* @param title The title to display (contact or channel name).
* @param channelIndex The index of the current channel, if applicable.
* @param mismatchKey True if there's a key mismatch for the current PKC.
* @param onNavigateBack Callback for the navigation icon.
* @param channels The set of all channels, used for the [SecurityIcon].
* @param channelIndexParam The specific channel index for the [SecurityIcon].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MessageTopBar(
title: String,
channelIndex: Int?,
mismatchKey: Boolean,
onNavigateBack: () -> Unit,
channels: AppOnlyProtos.ChannelSet?,
channelIndexParam: Int?,
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
onNavigateToQuickChatOptions: () -> Unit = {},
) = TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.width(10.dp))
if (channels != null && channelIndexParam != null) {
SecurityIcon(channels, channelIndexParam)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
},
actions = {
MessageTopBarActions(
showQuickChat,
onToggleQuickChat,
onNavigateToQuickChatOptions,
channelIndex,
mismatchKey,
)
},
)
@Composable
private fun MessageTopBarActions(
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
onNavigateToQuickChatOptions: () -> Unit,
channelIndex: Int?,
mismatchKey: Boolean,
) {
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
}
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }, enabled = true) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.overflow_menu))
}
OverFlowMenu(
expanded = expanded,
onDismiss = { expanded = false },
showQuickChat = showQuickChat,
onToggleQuickChat = onToggleQuickChat,
onNavigateToQuickChatOptions = onNavigateToQuickChatOptions,
)
}
}
@Composable
private fun OverFlowMenu(
expanded: Boolean,
onDismiss: () -> Unit,
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
onNavigateToQuickChatOptions: () -> Unit,
) {
if (expanded) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
val quickChatToggleTitle =
if (showQuickChat) {
stringResource(R.string.quick_chat_hide)
} else {
stringResource(R.string.quick_chat_show)
}
DropdownMenuItem(
text = { Text(quickChatToggleTitle) },
onClick = {
onDismiss()
onToggleQuickChat()
},
leadingIcon = {
Icon(
imageVector =
if (showQuickChat) {
Icons.Default.SpeakerNotesOff
} else {
Icons.Default.SpeakerNotes
},
contentDescription = quickChatToggleTitle,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.quick_chat)) },
onClick = {
onDismiss()
onNavigateToQuickChatOptions()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.ChatBubbleOutline,
contentDescription = stringResource(id = R.string.quick_chat),
)
},
)
}
}
}
/**
* A row of quick chat action buttons.
*
* @param enabled Whether the buttons should be enabled.
* @param actions The list of [QuickChatAction]s to display.
* @param onClick Callback when a quick chat button is clicked.
*/
@Composable
private fun QuickChatRow(
modifier: Modifier = Modifier,
enabled: Boolean,
actions: List<QuickChatAction>,
onClick: (QuickChatAction) -> Unit,
) {
val alertActionMessage = stringResource(R.string.alert_bell_text)
val alertAction =
remember(alertActionMessage) {
// Memoize if content is static
QuickChatAction(
name = "🔔",
message = "🔔 $alertActionMessage ", // Bell character added to message
mode = QuickChatAction.Mode.Append,
position = -1, // Assuming -1 means it's a special prepended action
)
}
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) }
}
}
}
private const val MAX_LINES = 3
/**
* The text input field for composing messages.
*
* @param isEnabled Whether the input field should be enabled.
* @param textFieldState The [TextFieldState] managing the input's text.
* @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
*/
@Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField
@Composable
private fun MessageInput(
isEnabled: Boolean,
textFieldState: TextFieldState,
modifier: Modifier = Modifier,
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
onSendMessage: () -> Unit,
) {
val currentText = textFieldState.text.toString()
val currentByteLength =
remember(currentText) {
// Recalculate only when text changes
currentText.toByteArray(StandardCharsets.UTF_8).size
}
val isOverLimit = currentByteLength > maxByteSize
val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled
OutlinedTextField(
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
state = textFieldState,
lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
label = { Text(stringResource(R.string.message_input_label)) },
enabled = isEnabled,
shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()),
isError = isOverLimit,
placeholder = { Text(stringResource(R.string.type_a_message)) },
supportingText = {
if (isEnabled) { // Only show supporting text if input is enabled
Text(
text = "$currentByteLength/$maxByteSize",
style = MaterialTheme.typography.bodySmall,
color =
if (isOverLimit) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
)
}
},
// Direct byte limiting via inputTransformation in TextFieldState is complex.
// The current approach (show error, disable send) is generally preferred for UX.
// If strict real-time byte trimming is required, it needs careful handling of
// cursor position and multi-byte characters, likely outside simple inputTransformation.
trailingIcon = {
IconButton(onClick = { if (canSend) onSendMessage() }, enabled = canSend) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(id = R.string.send),
)
}
},
)
}
@PreviewLightDark
@Composable
private fun MessageInputPreview() {
AppTheme {
Surface {
Column(modifier = Modifier.padding(8.dp)) {
MessageInput(isEnabled = true, textFieldState = rememberTextFieldState("Hello"), onSendMessage = {})
Spacer(Modifier.size(16.dp))
MessageInput(isEnabled = false, textFieldState = rememberTextFieldState("Disabled"), onSendMessage = {})
Spacer(Modifier.size(16.dp))
MessageInput(
isEnabled = true,
textFieldState =
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
"and cause an error state display for the user to see clearly.",
),
onSendMessage = {},
maxByteSize = 50, // Test with a smaller limit
)
Spacer(Modifier.size(16.dp))
// Test Japanese characters (multi-byte)
MessageInput(
isEnabled = true,
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
onSendMessage = {},
maxByteSize = 10,
// Each char is 3 bytes, so "こん" (6 bytes) is ok, "こんに" (9 bytes) is ok, "こんにち"
// (12 bytes) is over
)
}
}
}
}

View file

@ -1,220 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message
import androidx.annotation.StringRes
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.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.message.components.MessageItem
import com.geeksville.mesh.ui.message.components.ReactionDialog
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
@Composable
fun DeliveryInfo(
@StringRes title: Int,
@StringRes text: Int? = null,
onConfirm: (() -> Unit) = {},
onDismiss: () -> Unit = {},
resendOption: Boolean,
) = AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(id = R.string.close))
}
},
confirmButton = {
if (resendOption) {
FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) {
Text(text = stringResource(id = R.string.resend))
}
}
},
title = {
Text(
text = stringResource(id = title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
text?.let {
Text(
text = stringResource(id = it),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.surface,
)
@Suppress("LongMethod")
@Composable
internal fun MessageList(
nodes: List<Node>,
ourNode: Node?,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
messages: List<Message>,
selectedIds: MutableState<Set<Long>>,
onUnreadChanged: (Long) -> Unit,
onSendReaction: (String, Int) -> Unit,
onClickChip: (Node) -> Unit,
onDeleteMessages: (List<Long>) -> Unit,
onSendMessage: (messageText: String, contactKey: String) -> Unit,
contactKey: String,
onReply: (Message?) -> Unit,
) {
val haptics = LocalHapticFeedback.current
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
AutoScrollToBottom(listState, messages)
UpdateUnreadCount(listState, messages, onUnreadChanged)
var showStatusDialog by remember { mutableStateOf<Message?>(null) }
if (showStatusDialog != null) {
val msg = showStatusDialog ?: return
val (title, text) = msg.getStatusStringRes()
DeliveryInfo(
title = title,
text = text,
onConfirm = {
val deleteList: List<Long> = listOf(msg.uuid)
onDeleteMessages(deleteList)
showStatusDialog = null
onSendMessage(msg.text, contactKey)
},
onDismiss = { showStatusDialog = null },
resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false,
)
}
var showReactionDialog by remember { mutableStateOf<List<Reaction>?>(null) }
if (showReactionDialog != null) {
val reactions = showReactionDialog ?: return
ReactionDialog(reactions) { showReactionDialog = null }
}
fun MutableState<Set<Long>>.toggle(uuid: Long) = if (value.contains(uuid)) {
value -= uuid
} else {
value += uuid
}
val coroutineScope = rememberCoroutineScope()
LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) {
items(messages, key = { it.uuid }) { msg ->
if (ourNode != null) {
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
val node by remember { derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } }
MessageItem(
modifier = Modifier.animateItem(),
node = node,
ourNode = ourNode,
message = msg,
selected = selected,
onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) },
onLongClick = {
selectedIds.toggle(msg.uuid)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClickChip = onClickChip,
onStatusClick = { showStatusDialog = msg },
onReply = { onReply(msg) },
emojis = msg.emojis,
sendReaction = { onSendReaction(it, msg.packetId) },
onShowReactions = { showReactionDialog = msg.emojis },
onNavigateToOriginalMessage = {
coroutineScope.launch {
val targetIndex = messages.indexOfFirst { it.packetId == msg.replyId }
if (targetIndex != -1) {
listState.animateScrollToItem(index = targetIndex)
}
}
},
)
}
}
}
}
@Composable
private fun <T> AutoScrollToBottom(listState: LazyListState, list: List<T>, itemThreshold: Int = 3) = with(listState) {
val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } }
if (shouldAutoScroll) {
LaunchedEffect(list) {
if (!isScrollInProgress) {
scrollToItem(0)
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun UpdateUnreadCount(listState: LazyListState, messages: List<Message>, onUnreadChanged: (Long) -> Unit) {
LaunchedEffect(messages) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(timeoutMillis = 500L)
.collectLatest { index ->
val lastUnreadIndex = messages.indexOfLast { !it.read }
if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) {
val visibleMessage = messages[index]
onUnreadChanged(visibleMessage.receivedTime)
}
}
}
}

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message
import org.meshtastic.core.database.model.Node
/**
* Defines the various user interactions that can occur on the [MessageScreen]. These events are typically handled by
* the [com.geeksville.mesh.model.UIViewModel].
*/
internal sealed interface MessageScreenEvent {
/** Send a new text message. */
data class SendMessage(val text: String, val replyingToPacketId: Int? = null) : MessageScreenEvent
/** Send an emoji reaction to a specific message. */
data class SendReaction(val emoji: String, val messageId: Int) : MessageScreenEvent
/** Delete one or more selected messages. */
data class DeleteMessages(val ids: List<Long>) : MessageScreenEvent
/** Mark messages up to a certain ID as read. */
data class ClearUnreadCount(val lastReadMessageId: Long) : MessageScreenEvent
/** Handle an action from a node's context menu. */
data class NodeDetails(val node: Node) : MessageScreenEvent
/** Set the title of the screen (typically the contact or channel name). */
data class SetTitle(val title: String) : MessageScreenEvent
/** Navigate to a different message thread. */
data class NavigateToMessages(val contactKey: String) : MessageScreenEvent
/** Navigate to the details screen for a specific node. */
data class NavigateToNodeDetails(val nodeNum: Int) : MessageScreenEvent
/** Navigate back to the previous screen. */
data object NavigateBack : MessageScreenEvent
/** Copy the given text to the clipboard. */
data class CopyToClipboard(val text: String) : MessageScreenEvent
}

View file

@ -1,214 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.service.MeshServiceNotifications
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.sharedContact
import timber.log.Timber
import javax.inject.Inject
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
@HiltViewModel
class MessageViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
private val serviceRepository: ServiceRepository,
private val packetRepository: PacketRepository,
private val uiPrefs: UiPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
) : ViewModel() {
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
val ourNodeInfo = nodeRepository.ourNodeInfo
val connectionState = serviceRepository.connectionState
val nodeList: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val channels =
radioConfigRepository.channelSetFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
channelSet {},
)
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _showQuickChat
val quickChatActions =
quickChatActionRepository
.getAllActions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val messagesForContactKey: StateFlow<List<Message>> =
contactKeyForMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
contactKeyForMessages.value = contactKey
return messagesForContactKey
}
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }
onChanged(toggled)
}
}
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
/**
* Sends a message to a contact or channel.
*
* If the message is a direct message (no channel specified), this function will:
* - If the device firmware version is older than 2.7.12, it will mark the destination node as a favorite to prevent
* it from being removed from the on-device node database.
* - If the device firmware version is 2.7.12 or newer, it will send a shared contact to the destination node.
*
* @param str The message content.
* @param contactKey The unique contact key, which is a combination of channel (optional) and node ID. Defaults to
* broadcasting on channel 0.
* @param replyId The ID of the message this is a reply to, if any.
*/
@Suppress("NestedBlockDepth")
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
// if the destination is a node, we need to ensure it's a
// favorite so it does not get removed from the on-device node database.
if (channel == null) { // no channel specified, so we assume it's a direct message
val fwVersion = ourNodeInfo.value?.metadata?.firmwareVersion
val destNode = nodeRepository.getNode(dest)
fwVersion?.let { fw ->
val ver = DeviceVersion(asString = fw)
val verifiedSharedContactsVersion =
DeviceVersion(
asString = VERIFIED_CONTACT_FIRMWARE_CUTOFF,
) // Version cutover to verified shared contacts
if (ver >= verifiedSharedContactsVersion) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite) {
favoriteNode(destNode)
}
}
}
}
val p = DataPacket(dest, channel ?: 0, str, replyId)
sendDataPacket(p)
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
fun deleteMessages(uuidList: List<Long>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.clearUnreadCount(contact, timestamp)
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
}
private fun favoriteNode(node: Node) = viewModelScope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
Timber.e(ex, "Favorite node error")
}
}
private fun sendSharedContact(node: Node) = viewModelScope.launch {
try {
val contact = sharedContact {
nodeNum = node.num
user = node.user
manuallyVerified = node.manuallyVerified
}
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
} catch (ex: RemoteException) {
Timber.e(ex, "Send shared contact error")
}
}
private fun sendDataPacket(p: DataPacket) {
try {
serviceRepository.meshService?.send(p)
} catch (ex: RemoteException) {
Timber.e("Send DataPacket error: ${ex.message}")
}
}
}

View file

@ -1,373 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.UIViewModel
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.core.ui.theme.AppTheme
@Composable
internal fun QuickChatScreen(
modifier: Modifier = Modifier,
viewModel: UIViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val actions by viewModel.quickChatActions.collectAsStateWithLifecycle()
var showActionDialog by remember { mutableStateOf<QuickChatAction?>(null) }
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
val list = actions.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
viewModel.updateActionPositions(list)
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(id = R.string.quick_chat),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Box(modifier = modifier.fillMaxSize().padding(innerPadding)) {
showActionDialog?.let {
EditQuickChatDialog(
action = it,
onSave = viewModel::addQuickChatAction,
onDelete = viewModel::deleteQuickChatAction,
) {
showActionDialog = null
}
}
LazyColumn(
modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current),
state = listState,
contentPadding = PaddingValues(16.dp),
) {
dragDropItemsIndexed(items = actions, dragDropState = dragDropState, key = { _, item -> item.uuid }) {
_,
action,
isDragging,
->
QuickChatItem(action = action, onEdit = { showActionDialog = it })
}
}
FloatingActionButton(
onClick = { showActionDialog = QuickChatAction(position = actions.size) },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.add))
}
}
}
}
@Suppress("MagicNumber")
private fun getMessageName(message: String): String = if (message.length <= 3) {
message.uppercase()
} else {
buildString {
append(message.first().uppercase())
append(message[message.length / 2].uppercase())
append(message.last().uppercase())
}
}
@OptIn(ExperimentalLayoutApi::class)
@Suppress("LongMethod")
@Composable
private fun EditQuickChatDialog(
action: QuickChatAction,
onSave: (QuickChatAction) -> Unit,
onDelete: (QuickChatAction) -> Unit,
onDismiss: () -> Unit,
) {
var actionInput by remember { mutableStateOf(action) }
val newQuickChat = remember { action.uuid == 0L }
val isInstant = actionInput.mode == QuickChatAction.Mode.Instant
val title = if (newQuickChat) R.string.quick_chat_new else R.string.quick_chat_edit
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (newQuickChat) {
focusRequester.requestFocus()
}
}
AlertDialog(
onDismissRequest = onDismiss,
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(id = title),
modifier = Modifier.fillMaxWidth(),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextFieldWithCounter(
label = stringResource(R.string.name),
value = actionInput.name,
maxSize = 5,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) {
actionInput = actionInput.copy(name = it.uppercase())
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextFieldWithCounter(
label = stringResource(id = R.string.message),
value = actionInput.message,
maxSize = 200,
getSize = { it.toByteArray().size + 1 },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
) {
actionInput = actionInput.copy(message = it)
if (newQuickChat) {
actionInput = actionInput.copy(name = getMessageName(it))
}
}
Spacer(modifier = Modifier.height(8.dp))
val (text, icon) =
if (isInstant) {
R.string.quick_chat_instant to Icons.Default.FastForward
} else {
R.string.quick_chat_append to Icons.Default.Add
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isInstant) {
Icon(imageVector = icon, contentDescription = stringResource(id = text))
Spacer(Modifier.width(12.dp))
}
Text(text = stringResource(text), modifier = Modifier.weight(1f))
Switch(
checked = isInstant,
onCheckedChange = { checked ->
actionInput =
actionInput.copy(
mode =
when (checked) {
true -> QuickChatAction.Mode.Instant
false -> QuickChatAction.Mode.Append
},
)
},
)
}
}
},
confirmButton = {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = Modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
if (!newQuickChat) {
Button(
modifier = Modifier.weight(1f),
onClick = {
onDelete(actionInput)
onDismiss()
},
) {
Text(text = stringResource(R.string.delete))
}
}
Button(
modifier = Modifier.weight(1f),
onClick = {
onSave(actionInput)
onDismiss()
},
enabled = actionInput.name.isNotEmpty() && actionInput.message.isNotEmpty(),
) {
Text(text = stringResource(R.string.save))
}
}
},
)
}
@Composable
private fun OutlinedTextFieldWithCounter(
label: String,
value: String,
modifier: Modifier = Modifier,
singleLine: Boolean = false,
maxSize: Int,
getSize: (String) -> Int = { it.length },
onValueChange: (String) -> Unit = {},
) = Column(modifier) {
var isFocused by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = {
if (getSize(it) <= maxSize) {
onValueChange(it)
}
},
modifier = Modifier.onFocusEvent { isFocused = it.isFocused },
label = { Text(text = label) },
singleLine = singleLine,
)
if (isFocused) {
Text(
text = "${getSize(value)}/$maxSize",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.End).padding(top = 4.dp, end = 16.dp),
)
}
}
@Composable
private fun QuickChatItem(
action: QuickChatAction,
modifier: Modifier = Modifier,
onEdit: (QuickChatAction) -> Unit = {},
) {
Card(modifier = modifier.fillMaxWidth().padding(8.dp), shape = RoundedCornerShape(12.dp)) {
ListItem(
leadingContent = {
if (action.mode == QuickChatAction.Mode.Instant) {
Icon(
imageVector = Icons.Default.FastForward,
contentDescription = stringResource(id = R.string.quick_chat_instant),
)
}
},
headlineContent = { Text(text = action.name) },
supportingContent = { Text(text = action.message) },
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { onEdit(action) }, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(id = R.string.quick_chat_edit),
)
}
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(id = R.string.quick_chat),
)
}
},
)
}
}
@PreviewLightDark
@Composable
private fun QuickChatItemPreview() {
AppTheme { QuickChatItem(action = QuickChatAction(name = "TST", message = "Test", position = 0)) }
}
@PreviewLightDark
@Composable
private fun EditQuickChatDialogPreview() {
AppTheme {
EditQuickChatDialog(
action = QuickChatAction(name = "TST", message = "Test", position = 0),
onSave = {},
onDelete = {},
onDismiss = {},
)
}
}

View file

@ -1,109 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.EmojiEmotions
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudUpload
import androidx.compose.material.icons.twotone.HowToReg
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.Modifier
import androidx.compose.ui.res.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
@Composable
fun ReactionButton(onSendReaction: (String) -> Unit = {}) {
var showEmojiPickerDialog by remember { mutableStateOf(false) }
if (showEmojiPickerDialog) {
EmojiPickerDialog(
onConfirm = { selectedEmoji ->
showEmojiPickerDialog = false
onSendReaction(selectedEmoji)
},
onDismiss = { showEmojiPickerDialog = false },
)
}
IconButton(onClick = { showEmojiPickerDialog = true }) {
Icon(imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.react))
}
}
@Composable
fun ReplyButton(onClick: () -> Unit = {}) = IconButton(
onClick = onClick,
content = {
Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = stringResource(R.string.reply))
},
)
@Composable
fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) =
AnimatedVisibility(visible = fromLocal) {
IconButton(onClick = onStatusClick) {
Icon(
imageVector =
when (status) {
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
else -> Icons.TwoTone.Warning
},
contentDescription = stringResource(R.string.message_delivery_status),
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MessageActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
status: MessageStatus?,
onSendReaction: (String) -> Unit = {},
onSendReply: () -> Unit = {},
onStatusClick: () -> Unit = {},
) {
Row(modifier = modifier.wrapContentSize()) {
ReactionButton { onSendReaction(it) }
ReplyButton { onSendReply() }
MessageStatusButton(
onStatusClick = onStatusClick,
status = status ?: MessageStatus.UNKNOWN,
fromLocal = isLocal,
)
}
}

View file

@ -1,346 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.AutoLinkText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MessageItemColors
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
internal fun MessageItem(
modifier: Modifier = Modifier,
node: Node,
ourNode: Node,
message: Message,
selected: Boolean,
onReply: () -> Unit = {},
sendReaction: (String) -> Unit = {},
onShowReactions: () -> Unit = {},
emojis: List<Reaction> = emptyList(),
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onClickChip: (Node) -> Unit = {},
onStatusClick: () -> Unit = {},
onNavigateToOriginalMessage: (Int) -> Unit = {},
) = Column(
modifier =
modifier
.fillMaxWidth()
.background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background),
) {
val containsBel = message.text.contains('\u0007')
val containerColor =
Color(
if (message.fromLocal) {
ourNode.colors.second
} else {
node.colors.second
},
)
.copy(alpha = 0.2f)
val cardColors =
CardDefaults.cardColors()
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
val messageModifier =
Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp)
.then(
if (containsBel) {
Modifier.border(2.dp, MessageItemColors.Red, shape = MaterialTheme.shapes.medium)
} else {
Modifier
},
)
Box {
Card(
modifier =
Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart)
.padding(
top = 4.dp,
start = if (!message.fromLocal) 0.dp else 16.dp,
end = if (message.fromLocal) 0.dp else 16.dp,
)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.then(messageModifier),
colors = cardColors,
) {
Column(modifier = Modifier.fillMaxWidth()) {
OriginalMessageSnippet(
message = message,
ourNode = ourNode,
cardColors = cardColors,
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val chipNode = if (message.fromLocal) ourNode else node
NodeChip(node = chipNode, onClick = onClickChip)
Text(
text = with(if (message.fromLocal) ourNode.user else node.user) { "$longName ($id)" },
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f, fill = true),
)
if (message.viaMqtt) {
Icon(
Icons.Default.Cloud,
contentDescription = stringResource(R.string.via_mqtt),
modifier = Modifier.size(16.dp),
)
}
MessageActions(
isLocal = message.fromLocal,
status = message.status,
onSendReaction = sendReaction,
onSendReply = onReply,
onStatusClick = onStatusClick,
)
}
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
AutoLinkText(
modifier = Modifier.fillMaxWidth(),
text = message.text,
style = MaterialTheme.typography.bodyMedium,
color = cardColors.contentColor,
)
val topPadding = if (!message.fromLocal) 2.dp else 0.dp
Row(
modifier = Modifier.fillMaxWidth().padding(top = topPadding, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (!message.fromLocal) {
if (message.hopsAway == 0) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Snr(message.snr)
Rssi(message.rssi)
}
} else {
Text(
text = stringResource(R.string.hops_away_template, message.hopsAway),
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (containsBel) {
Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
}
Text(text = message.time, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
ReactionRow(
modifier = Modifier.fillMaxWidth(),
reactions = emojis,
onSendReaction = sendReaction,
onShowReactions = onShowReactions,
)
}
@Composable
private fun OriginalMessageSnippet(
message: Message,
ourNode: Node,
cardColors: CardColors = CardDefaults.cardColors(),
onNavigateToOriginalMessage: (Int) -> Unit,
) {
val originalMessage = message.originalMessage
if (originalMessage != null && originalMessage.packetId != 0) {
val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node
OutlinedCard(
modifier =
Modifier.fillMaxWidth().padding(4.dp).clickable {
onNavigateToOriginalMessage(originalMessage.packetId)
},
colors = cardColors,
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Default.FormatQuote,
contentDescription = stringResource(R.string.reply), // Add to strings.xml
)
Text(
text = originalMessageNode.user.shortName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier.weight(1f, fill = true),
text = originalMessage.text, // Should not be null if isAReply is true
style = MaterialTheme.typography.bodySmall,
maxLines = 1, // Keep snippet brief
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@PreviewLightDark
@Composable
private fun MessageItemPreview() {
val sent =
Message(
text = stringResource(R.string.sample_message),
time = "10:00",
fromLocal = true,
status = MessageStatus.DELIVERED,
snr = 20.5f,
rssi = 90,
hopsAway = 0,
uuid = 1L,
receivedTime = System.currentTimeMillis(),
node = NodePreviewParameterProvider().mickeyMouse,
read = false,
routingError = 0,
packetId = 4545,
emojis = listOf(),
replyId = null,
viaMqtt = false,
)
val received =
Message(
text = "This is a received message",
time = "10:10",
fromLocal = false,
status = MessageStatus.RECEIVED,
snr = 2.5f,
rssi = 90,
hopsAway = 0,
uuid = 2L,
receivedTime = System.currentTimeMillis(),
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
packetId = 4545,
emojis = listOf(),
replyId = null,
viaMqtt = false,
)
val receivedWithOriginalMessage =
Message(
text = "This is a received message w/ original, this is a longer message to test next-lining.",
time = "10:20",
fromLocal = false,
status = MessageStatus.RECEIVED,
snr = 2.5f,
rssi = 90,
hopsAway = 2,
uuid = 2L,
receivedTime = System.currentTimeMillis(),
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
packetId = 4545,
emojis = listOf(),
replyId = null,
originalMessage = received,
viaMqtt = true,
)
AppTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp)) {
MessageItem(
message = sent,
node = sent.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
ourNode = sent.node,
)
MessageItem(
message = received,
node = received.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
ourNode = sent.node,
)
MessageItem(
message = receivedWithOriginalMessage,
node = receivedWithOriginalMessage.node,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
ourNode = sent.node,
)
}
}
}

View file

@ -1,180 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.message.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.ui.component.BottomSheetDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.MeshProtos
@Composable
private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) {
BadgedBox(
badge = {
if (emojiCount > 1) {
Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) }
}
},
) {
Surface(
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape,
) {
Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReactionRow(
modifier: Modifier = Modifier,
reactions: List<Reaction> = emptyList(),
onSendReaction: (String) -> Unit = {},
onShowReactions: () -> Unit = {},
) {
val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries
AnimatedVisibility(emojiList.isNotEmpty()) {
LazyRow(
modifier = modifier.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
items(emojiList.size) { index ->
val entry = emojiList.elementAt(index)
ReactionItem(
emoji = entry.key,
emojiCount = entry.value,
onClick = { onSendReaction(entry.key) },
onLongClick = onShowReactions,
)
}
}
}
}
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
@Composable
fun ReactionDialog(reactions: List<Reaction>, onDismiss: () -> Unit = {}) =
BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
val groupedEmojis = reactions.groupBy { it.emoji }
var selectedEmoji by remember { mutableStateOf<String?>(null) }
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
Text(
text = "$emoji${reactions.size}",
modifier =
Modifier.clip(CircleShape)
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
.padding(8.dp)
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
style = MaterialTheme.typography.bodyMedium,
)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(filteredReactions) { reaction ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = reaction.user.longName, style = MaterialTheme.typography.titleMedium)
Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge)
}
}
}
}
@PreviewLightDark
@Composable
fun ReactionItemPreview() {
AppTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
ReactionItem(emoji = "\uD83D\uDE42")
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
ReactionButton()
}
}
}
@Preview
@Composable
fun ReactionRowPreview() {
AppTheme {
ReactionRow(
reactions =
listOf(
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L,
),
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L,
),
),
)
}
}

View file

@ -36,10 +36,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme

View file

@ -57,14 +57,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ui.sharing.AddContactFAB
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.component.supportsQrCodeSharing
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField

View file

@ -469,7 +469,7 @@ private fun QrCodeImage(
) = Image(
painter =
channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = com.geeksville.mesh.R.drawable.qrcode),
?: painterResource(id = org.meshtastic.core.ui.R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code),
modifier = modifier,
contentScale = ContentScale.Inside,

View file

@ -1,306 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.sharing
import android.Manifest
import android.graphics.Bitmap
import android.net.Uri
import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.QrCodeScanner
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.protobuf.ByteString
import com.google.protobuf.Descriptors
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos
import timber.log.Timber
import java.net.MalformedURLException
/**
* Composable FloatingActionButton to initiate scanning a QR code for adding a contact. Handles camera permission
* requests using Accompanist Permissions.
*
* @param modifier Modifier for this composable.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
sharedContact: AdminProtos.SharedContact?,
modifier: Modifier = Modifier,
onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit,
) {
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
val uri = result.contents.toUri()
val sharedContact =
try {
uri.toSharedContact()
} catch (ex: MalformedURLException) {
Timber.e("URL was malformed: ${ex.message}")
null
}
if (sharedContact != null) {
onSharedContactRequested(sharedContact)
}
}
}
sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
fun zxingScan() {
Timber.d("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
zxingScan.setCameraId(CAMERA_ID)
zxingScan.setPrompt("")
zxingScan.setBeepEnabled(false)
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
barcodeLauncher.launch(zxingScan)
}
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(cameraPermissionState.status) {
if (cameraPermissionState.status.isGranted) {
Timber.d("Camera permission granted")
} else {
Timber.d("Camera permission denied")
}
}
FloatingActionButton(
modifier = modifier,
onClick = {
if (cameraPermissionState.status.isGranted) {
zxingScan()
} else {
cameraPermissionState.launchPermissionRequest()
}
},
) {
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(R.string.scan_qr_code))
}
}
@Composable
private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
painter =
uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) }
?: painterResource(id = com.geeksville.mesh.R.drawable.qrcode),
contentDescription = stringResource(R.string.qr_code),
modifier = modifier,
contentScale = ContentScale.Inside,
)
@Composable
private fun SharedContact(contactUri: Uri) {
Column {
QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
}
}
}
/**
* Displays a dialog with the contact's information as a QR code and URI.
*
* @param contact The node representing the contact to share. Null if no contact is selected.
* @param onDismiss Callback invoked when the dialog is dismissed.
*/
@Composable
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
if (contact == null) return
val sharedContact = AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build()
val uri = sharedContact.getSharedContactUrl()
SimpleAlertDialog(
title = R.string.share_contact,
text = {
Column {
Text(contact.user.longName)
SharedContact(contactUri = uri)
}
},
onDismiss = onDismiss,
)
}
@Preview
@Composable
private fun ShareContactPreview() {
SharedContact(contactUri = "https://example.com".toUri())
}
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
val Uri.qrCode: Bitmap?
get() =
try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
val barcodeEncoder = BarcodeEncoder()
barcodeEncoder.createBitmap(bitMatrix)
} catch (ex: WriterException) {
Timber.e("URL was too complex to render as barcode: ${ex.message}")
null
}
private const val REQUIRED_MIN_FIRMWARE = "2.6.8"
private const val BARCODE_PIXEL_SIZE = 960
private const val MESHTASTIC_HOST = "meshtastic.org"
private const val CONTACT_SHARE_PATH = "/v/"
/** Prefix for Meshtastic contact sharing URLs. */
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
private const val CAMERA_ID = 0
/** Checks if the device firmware version supports QR code sharing. */
fun DeviceVersion.supportsQrCodeSharing(): Boolean = this >= DeviceVersion(REQUIRED_MIN_FIRMWARE)
/**
* Converts a URI to a [AdminProtos.SharedContact].
*
* @throws MalformedURLException if the URI is not a valid Meshtastic contact sharing URL.
*/
@Suppress("MagicNumber")
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): AdminProtos.SharedContact {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS))
return url.toBuilder().build()
}
/** Converts a [AdminProtos.SharedContact] to its corresponding URI representation. */
fun AdminProtos.SharedContact.getSharedContactUrl(): Uri {
val bytes = this.toByteArray() ?: ByteArray(0)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return "$URL_PREFIX$enc".toUri()
}
/** Compares two [MeshProtos.User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String {
val changes = mutableListOf<String>()
// Iterate over all fields in the User message descriptor
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
val fieldName = fieldDescriptor.name
val oldValue = if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null
val newValue = if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null
if (oldValue != newValue) {
val oldValueString = valueToString(oldValue, fieldDescriptor)
val newValueString = valueToString(newValue, fieldDescriptor)
changes.add("$fieldName: $oldValueString -> $newValueString")
}
}
return if (changes.isEmpty()) {
"No changes detected."
} else {
"Changes:\n" + changes.joinToString("\n")
}
}
/** Converts a [MeshProtos.User] object to a string representation of its fields and values. */
fun userFieldsToString(user: MeshProtos.User): String {
val fieldLines = mutableListOf<String>()
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
val fieldName = fieldDescriptor.name
if (user.hasField(fieldDescriptor)) {
val value = user.getField(fieldDescriptor)
val valueString = valueToString(value, fieldDescriptor) // Using the helper from previous example
fieldLines.add("$fieldName: $valueString")
} else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.isOptional) {
val defaultValue = fieldDescriptor.defaultValue
val valueString =
if (fieldDescriptor.isRepeated) {
"[]" // Empty list
} else if (user.hasField(fieldDescriptor)) {
valueToString(user.getField(fieldDescriptor), fieldDescriptor)
} else {
valueToString(defaultValue, fieldDescriptor)
}
fieldLines.add("$fieldName: $valueString")
}
}
return if (fieldLines.isEmpty()) {
"User object has no fields set."
} else {
fieldLines.joinToString("\n")
}
}
private fun valueToString(value: Any?, fieldDescriptor: Descriptors.FieldDescriptor): String {
if (value == null) {
return "null"
}
return when (fieldDescriptor.type) {
Descriptors.FieldDescriptor.Type.BYTES -> {
// For ByteString, you might want to display it as hex or Base64
// For simplicity, here we'll just show its size.
if (value is ByteString) {
Base64.encodeToString(value.toByteArray(), Base64.DEFAULT).trim()
} else {
value.toString().trim()
}
}
// Add more custom formatting for other types if needed
else -> value.toString().trim()
}
}

View file

@ -1,72 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.sharing
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.proto.AdminProtos
/** A dialog for importing a shared contact that was scanned from a QR code. */
@Composable
fun SharedContactDialog(
sharedContact: AdminProtos.SharedContact,
onDismiss: () -> Unit,
viewModel: SharedContactViewModel = hiltViewModel(),
) {
val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle()
val nodeNum = sharedContact.nodeNum
val node = unfilteredNodes.find { it.num == nodeNum }
SimpleAlertDialog(
title = R.string.import_shared_contact,
text = {
Column {
if (node != null) {
Text(text = stringResource(R.string.import_known_shared_contact_text))
if (node.user.publicKey.size() > 0 && node.user.publicKey != sharedContact.user?.publicKey) {
Text(
text = stringResource(R.string.public_key_changed),
color = MaterialTheme.colorScheme.error,
)
}
HorizontalDivider()
Text(text = compareUsers(node.user, sharedContact.user))
} else {
Text(text = userFieldsToString(sharedContact.user))
}
}
},
dismissText = stringResource(R.string.cancel),
onDismiss = onDismiss,
confirmText = stringResource(R.string.import_label),
onConfirm = {
viewModel.addSharedContact(sharedContact)
onDismiss()
},
)
}

View file

@ -1,53 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui.sharing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.AdminProtos
import javax.inject.Inject
@HiltViewModel
class SharedContactViewModel
@Inject
constructor(
nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
val unfilteredNodes: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) }
}