Merge pull request #129 from torlando-tech/feature/message-replies

feat: add Signal-style reply-to-message feature
This commit is contained in:
Torlando
2025-12-21 19:38:10 -06:00
committed by GitHub
22 changed files with 2848 additions and 47 deletions

View File

@@ -334,9 +334,10 @@ interface IReticulumService {
* @param imageData Optional image data bytes (null if none)
* @param imageFormat Optional image format string (e.g., "jpg", "png", null if none)
* @param fileAttachments Optional map of filename -> file bytes (null if none)
* @param replyToMessageId Optional message ID being replied to (stored in LXMF field 16)
* @return JSON string with result: {"success": true, "message_hash": "...", "delivery_method": "..."}
*/
String sendLxmfMessageWithMethod(in byte[] destHash, String content, in byte[] sourceIdentityPrivateKey, String deliveryMethod, boolean tryPropagationOnFail, in byte[] imageData, String imageFormat, in Map fileAttachments);
String sendLxmfMessageWithMethod(in byte[] destHash, String content, in byte[] sourceIdentityPrivateKey, String deliveryMethod, boolean tryPropagationOnFail, in byte[] imageData, String imageFormat, in Map fileAttachments, String replyToMessageId);
/**
* Provide an alternative relay for message retry.

View File

@@ -1609,6 +1609,7 @@ class ServiceReticulumProtocol(
imageData: ByteArray?,
imageFormat: String?,
fileAttachments: List<Pair<String, ByteArray>>?,
replyToMessageId: String?,
): Result<MessageReceipt> {
return runCatching {
val service = this.service ?: throw IllegalStateException("Service not bound")
@@ -1635,6 +1636,7 @@ class ServiceReticulumProtocol(
imageData,
imageFormat,
fileAttachmentsMap,
replyToMessageId,
)
val result = JSONObject(resultJson)

View File

@@ -614,6 +614,7 @@ class ReticulumServiceBinder(
imageData: ByteArray?,
imageFormat: String?,
fileAttachments: Map<*, *>?,
replyToMessageId: String?,
): String {
return try {
wrapperManager.withWrapper { wrapper ->
@@ -634,6 +635,7 @@ class ReticulumServiceBinder(
imageData,
imageFormat,
fileAttachmentsList,
replyToMessageId,
)
// Use PythonResultConverter to properly convert Python dict to JSON
// (bytes values like message_hash need Base64 encoding)

View File

@@ -0,0 +1,435 @@
package com.lxmf.messenger.ui.components
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.mutableFloatStateOf
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.lxmf.messenger.ui.model.ReplyPreviewUi
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Swipe threshold for triggering reply action.
*/
private val SWIPE_THRESHOLD = 72.dp
/**
* Maximum swipe distance to prevent over-swiping.
*/
private val MAX_SWIPE = 100.dp
/**
* Wrapper component that adds swipe-to-reply gesture to a message bubble.
*
* The swipe direction is toward the center:
* - Received messages (isFromMe = false): swipe right
* - Sent messages (isFromMe = true): swipe left
*
* When the swipe threshold is reached:
* - Haptic feedback is triggered
* - Reply icon appears behind the bubble
* - Releasing triggers the onReply callback
*
* @param isFromMe Whether this is a sent message (affects swipe direction)
* @param onReply Callback when swipe-to-reply is triggered
* @param content The message bubble content to wrap
*/
@Composable
fun SwipeableMessageBubble(
isFromMe: Boolean,
onReply: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
val thresholdPx = with(density) { SWIPE_THRESHOLD.toPx() }
val maxSwipePx = with(density) { MAX_SWIPE.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var hasTriggeredHaptic by remember { mutableStateOf(false) }
var shouldTriggerReply by remember { mutableStateOf(false) }
// Animate the return to center when released
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
label = "swipe_offset",
)
// Calculate reply icon visibility based on swipe progress
val swipeProgress = abs(animatedOffsetX) / thresholdPx
val replyIconAlpha = (swipeProgress * 2).coerceIn(0f, 1f)
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = if (isFromMe) Alignment.CenterEnd else Alignment.CenterStart,
) {
// Reply icon behind the bubble
Box(
modifier = Modifier
.alpha(replyIconAlpha)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Reply,
contentDescription = "Reply",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
// Message bubble with swipe gesture
Box(
modifier = Modifier
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
.pointerInput(isFromMe) {
detectHorizontalDragGestures(
onDragStart = {
hasTriggeredHaptic = false
shouldTriggerReply = false
},
onDragEnd = {
if (shouldTriggerReply) {
onReply()
}
offsetX = 0f
hasTriggeredHaptic = false
shouldTriggerReply = false
},
onDragCancel = {
offsetX = 0f
hasTriggeredHaptic = false
shouldTriggerReply = false
},
onHorizontalDrag = { _, dragAmount ->
// Determine valid swipe direction
val newOffset = offsetX + dragAmount
val isValidDirection = if (isFromMe) {
// Sent messages: swipe left (negative offset)
newOffset <= 0
} else {
// Received messages: swipe right (positive offset)
newOffset >= 0
}
if (isValidDirection) {
// Clamp to max swipe distance
offsetX = if (isFromMe) {
newOffset.coerceIn(-maxSwipePx, 0f)
} else {
newOffset.coerceIn(0f, maxSwipePx)
}
// Check if threshold reached for haptic feedback
val absOffset = abs(offsetX)
if (absOffset >= thresholdPx && !hasTriggeredHaptic) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hasTriggeredHaptic = true
shouldTriggerReply = true
} else if (absOffset < thresholdPx) {
shouldTriggerReply = false
}
}
},
)
},
) {
content()
}
}
}
/**
* Reply preview displayed inside a message bubble.
*
* Shows a colored accent bar on the left with sender name and truncated content.
* Includes icons for image/file attachments when present.
* Clickable to jump to the original message.
*
* @param replyPreview The reply preview data to display
* @param isFromMe Whether the current message is from the user (affects colors)
* @param onClick Callback when the preview is tapped (for jump-to-original)
*/
@Composable
fun ReplyPreviewBubble(
replyPreview: ReplyPreviewUi,
isFromMe: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val accentColor = if (isFromMe) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f)
} else {
MaterialTheme.colorScheme.primary
}
val contentColor = if (isFromMe) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
val backgroundColor = if (isFromMe) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
color = backgroundColor,
shape = RoundedCornerShape(8.dp),
) {
Row(modifier = Modifier.padding(8.dp)) {
// Accent bar on left
Box(
modifier = Modifier
.width(3.dp)
.height(36.dp)
.background(accentColor, RoundedCornerShape(2.dp)),
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
// Sender name
Text(
text = replyPreview.senderName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = accentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
// Content preview with attachment indicators
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (replyPreview.hasImage) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Image",
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp),
)
}
if (replyPreview.hasFileAttachment) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "File",
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp),
)
replyPreview.firstFileName?.let { filename ->
Text(
text = filename,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
}
if (replyPreview.contentPreview.isNotEmpty()) {
Text(
text = replyPreview.contentPreview,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (!replyPreview.hasImage && !replyPreview.hasFileAttachment) {
Text(
text = "Message",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.5f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
)
}
}
}
}
}
}
/**
* Reply input bar displayed above the message input when replying to a message.
*
* Shows "Replying to [name]" with a preview and a close button to cancel.
*
* @param replyPreview The reply preview data to display
* @param onCancelReply Callback when the close button is tapped
*/
@Composable
fun ReplyInputBar(
replyPreview: ReplyPreviewUi,
onCancelReply: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Reply icon
Icon(
imageVector = Icons.AutoMirrored.Filled.Reply,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
// Accent bar
Box(
modifier = Modifier
.width(3.dp)
.height(32.dp)
.background(
MaterialTheme.colorScheme.primary,
RoundedCornerShape(2.dp),
),
)
// Reply info
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replying to ${replyPreview.senderName}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (replyPreview.hasImage) {
Icon(
imageVector = Icons.Default.Image,
contentDescription = "Image",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = "Photo",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (replyPreview.hasFileAttachment && replyPreview.firstFileName != null) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "File",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = replyPreview.firstFileName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (replyPreview.contentPreview.isNotEmpty()) {
Text(
text = replyPreview.contentPreview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Close button
IconButton(
onClick = onCancelReply,
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cancel reply",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}

View File

@@ -37,6 +37,9 @@ fun Message.toMessageUi(): MessageUi {
val hasFiles = hasFileAttachmentsField(fieldsJson)
val fileAttachmentsList = if (hasFiles) parseFileAttachments(fieldsJson) else emptyList()
// Get reply-to message ID: prefer DB column, fallback to parsing field 16
val replyId = replyToMessageId ?: parseReplyToFromField16(fieldsJson)
return MessageUi(
id = id,
destinationHash = destinationHash,
@@ -52,9 +55,35 @@ fun Message.toMessageUi(): MessageUi {
fieldsJson = if ((hasImage && cachedImage == null) || hasFiles) fieldsJson else null,
deliveryMethod = deliveryMethod,
errorMessage = errorMessage,
replyToMessageId = replyId,
// Note: replyPreview is loaded asynchronously by the ViewModel
)
}
/**
* Parse the reply_to message ID from LXMF field 16 (app extensions).
*
* Field 16 is structured as: {"reply_to": "message_id", ...}
* This allows for future extensibility (reactions, mentions, etc.)
*
* @param fieldsJson The message's fields JSON
* @return The reply_to message ID, or null if not present or parsing fails
*/
@Suppress("SwallowedException", "ReturnCount") // Invalid JSON is expected to fail silently here
private fun parseReplyToFromField16(fieldsJson: String?): String? {
if (fieldsJson == null) return null
return try {
val fields = JSONObject(fieldsJson)
val field16 = fields.optJSONObject("16") ?: return null
// Check for JSON null value explicitly
if (field16.isNull("reply_to")) return null
val replyTo = field16.optString("reply_to", "")
replyTo.ifEmpty { null }
} catch (e: Exception) {
null
}
}
/**
* Check if the message has an image field (type 6) in its JSON.
* This is a fast check that doesn't decode anything.

View File

@@ -64,6 +64,16 @@ data class MessageUi(
* Used to quickly determine if file attachment UI should be rendered.
*/
val hasFileAttachments: Boolean = false,
/**
* ID of the message this is replying to, if any.
* Extracted from LXMF field 16 {"reply_to": "message_id"}.
*/
val replyToMessageId: String? = null,
/**
* Preview data for the message being replied to.
* Loaded asynchronously from the database. Null if not a reply or not yet loaded.
*/
val replyPreview: ReplyPreviewUi? = null,
)
/**
@@ -85,3 +95,26 @@ data class FileAttachmentUi(
val mimeType: String,
val index: Int,
)
/**
* UI representation of a reply preview.
*
* Contains the minimal data needed to display a reply preview in a message bubble.
* This includes sender info, truncated content, and attachment indicators.
*
* @property messageId The ID of the original message being replied to
* @property senderName "You" if from current user, otherwise the peer's display name
* @property contentPreview Truncated content (max 100 chars) for preview
* @property hasImage Whether the original message has an image attachment
* @property hasFileAttachment Whether the original message has file attachments
* @property firstFileName First filename if file attachments present
*/
@Immutable
data class ReplyPreviewUi(
val messageId: String,
val senderName: String,
val contentPreview: String,
val hasImage: Boolean = false,
val hasFileAttachment: Boolean = false,
val firstFileName: String? = null,
)

View File

@@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.AttachFile
import androidx.compose.material.icons.filled.Circle
@@ -72,6 +73,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -106,7 +108,11 @@ import com.lxmf.messenger.ui.model.LocationSharingState
import com.lxmf.messenger.util.LocationPermissionManager
import com.lxmf.messenger.ui.components.FileAttachmentOptionsSheet
import com.lxmf.messenger.ui.components.FileAttachmentPreviewRow
import com.lxmf.messenger.ui.components.ReplyInputBar
import com.lxmf.messenger.ui.components.ReplyPreviewBubble
import com.lxmf.messenger.ui.components.StarToggleButton
import com.lxmf.messenger.ui.components.SwipeableMessageBubble
import com.lxmf.messenger.ui.model.ReplyPreviewUi
import com.lxmf.messenger.ui.theme.MeshConnected
import com.lxmf.messenger.ui.theme.MeshOffline
import com.lxmf.messenger.util.FileAttachment
@@ -172,6 +178,15 @@ fun MessagingScreen(
}
}
// Reply state
val pendingReplyTo by viewModel.pendingReplyTo.collectAsStateWithLifecycle()
// Reply preview cache - maps message ID to its loaded reply preview
val replyPreviewCache by viewModel.replyPreviewCache.collectAsStateWithLifecycle()
// Track message positions for jump-to-original functionality
val messagePositions = remember { mutableStateMapOf<String, Int>() }
// Lifecycle-aware coroutine scope for image and file processing
val scope = rememberCoroutineScope()
@@ -522,6 +537,9 @@ fun MessagingScreen(
) { index ->
val message = pagingItems[index]
if (message != null) {
// Track message position for jump-to-original
messagePositions[message.id] = index
// Async image loading: check if this message has an uncached image
// Using loadedImageIds in the key triggers recomposition when
// the image is decoded and cached
@@ -530,13 +548,28 @@ fun MessagingScreen(
message.decodedImage == null &&
!loadedImageIds.contains(message.id)
// Trigger async loading if needed
// Trigger async image loading if needed
LaunchedEffect(message.id, needsImageLoading) {
if (needsImageLoading && message.fieldsJson != null) {
viewModel.loadImageAsync(message.id, message.fieldsJson)
}
}
// Async reply preview loading: check if this message has a reply
// that needs loading
val needsReplyPreviewLoading =
message.replyToMessageId != null &&
!replyPreviewCache.containsKey(message.id)
// Trigger async loading if needed
LaunchedEffect(message.id, needsReplyPreviewLoading) {
if (needsReplyPreviewLoading) {
message.replyToMessageId?.let { replyToId ->
viewModel.loadReplyPreviewAsync(message.id, replyToId)
}
}
}
// Get cached image if it was loaded after initial render
val cachedImage =
if (message.decodedImage == null && loadedImageIds.contains(message.id)) {
@@ -545,31 +578,55 @@ fun MessagingScreen(
message.decodedImage
}
// Create updated message with cached image
val displayMessage =
if (cachedImage != null && message.decodedImage == null) {
message.copy(decodedImage = cachedImage)
} else {
message
}
// Get cached reply preview if it was loaded after initial render
val cachedReplyPreview = replyPreviewCache[message.id]
MessageBubble(
message = displayMessage,
isFromMe = displayMessage.isFromMe,
clipboardManager = clipboardManager,
onViewDetails = onViewMessageDetails,
onRetry = { viewModel.retryFailedMessage(message.id) },
onFileAttachmentTap = { messageId, fileIndex, filename ->
selectedFileInfo = Triple(messageId, fileIndex, filename)
showFileOptionsSheet = true
},
// Create updated message with cached image and reply preview
val displayMessage = message.copy(
decodedImage = cachedImage ?: message.decodedImage,
replyPreview = cachedReplyPreview ?: message.replyPreview,
)
// Wrap in SwipeableMessageBubble for swipe-to-reply
SwipeableMessageBubble(
isFromMe = displayMessage.isFromMe,
onReply = { viewModel.setReplyTo(message.id) },
) {
MessageBubble(
message = displayMessage,
isFromMe = displayMessage.isFromMe,
clipboardManager = clipboardManager,
onViewDetails = onViewMessageDetails,
onRetry = { viewModel.retryFailedMessage(message.id) },
onFileAttachmentTap = { messageId, fileIndex, filename ->
selectedFileInfo = Triple(messageId, fileIndex, filename)
showFileOptionsSheet = true
},
onReply = { viewModel.setReplyTo(message.id) },
onReplyPreviewClick = { replyToId ->
// Jump to original message
messagePositions[replyToId]?.let { position ->
scope.launch {
listState.animateScrollToItem(position)
}
}
},
)
}
}
}
}
}
}
// Reply input bar (shown when replying to a message)
pendingReplyTo?.let { replyPreview ->
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = { viewModel.clearReplyTo() },
)
}
// Message Input Bar - at bottom of Column
MessageInputBar(
modifier =
@@ -713,6 +770,8 @@ fun MessageBubble(
onViewDetails: (messageId: String) -> Unit = {},
onRetry: () -> Unit = {},
onFileAttachmentTap: (messageId: String, fileIndex: Int, filename: String) -> Unit = { _, _, _ -> },
onReply: () -> Unit = {},
onReplyPreviewClick: (replyToMessageId: String) -> Unit = {},
) {
val hapticFeedback = LocalHapticFeedback.current
var showMenu by remember { mutableStateOf(false) }
@@ -760,6 +819,16 @@ fun MessageBubble(
vertical = 10.dp,
),
) {
// Display reply preview if this message is a reply
message.replyPreview?.let { replyPreview ->
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = isFromMe,
onClick = { onReplyPreviewClick(replyPreview.messageId) },
)
Spacer(modifier = Modifier.height(8.dp))
}
// Display image attachment if present (LXMF field 6 = IMAGE)
imageBitmap?.let { bitmap ->
Image(
@@ -871,6 +940,10 @@ fun MessageBubble(
} else {
null
},
onReply = {
onReply()
showMenu = false
},
)
}
}
@@ -885,6 +958,7 @@ fun MessageContextMenu(
isFailed: Boolean = false,
onViewDetails: (() -> Unit)? = null,
onRetry: (() -> Unit)? = null,
onReply: (() -> Unit)? = null,
) {
DropdownMenu(
expanded = expanded,
@@ -907,6 +981,20 @@ fun MessageContextMenu(
)
}
// Reply option
if (onReply != null) {
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Reply,
contentDescription = null,
)
},
text = { Text("Reply") },
onClick = onReply,
)
}
DropdownMenuItem(
leadingIcon = {
Icon(

View File

@@ -58,7 +58,7 @@ import com.lxmf.messenger.reticulum.model.Message as ReticulumMessage
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
@Suppress("TooManyFunctions") // ViewModel handles multiple UI operations
@Suppress("TooManyFunctions", "LargeClass") // ViewModel handles multiple UI operations
class MessagingViewModel
@Inject
constructor(
@@ -160,6 +160,10 @@ class MessagingViewModel
private val _loadedImageIds = MutableStateFlow<Set<String>>(emptySet())
val loadedImageIds: StateFlow<Set<String>> = _loadedImageIds.asStateFlow()
// Cache for loaded reply previews - maps message ID to its reply preview
private val _replyPreviewCache = MutableStateFlow<Map<String, com.lxmf.messenger.ui.model.ReplyPreviewUi>>(emptyMap())
val replyPreviewCache: StateFlow<Map<String, com.lxmf.messenger.ui.model.ReplyPreviewUi>> = _replyPreviewCache.asStateFlow()
// Contact status for current conversation - updates reactively
val isContactSaved: StateFlow<Boolean> =
_currentConversation
@@ -216,6 +220,47 @@ class MessagingViewModel
private val _contactToggleResult = MutableSharedFlow<ContactToggleResult>()
val contactToggleResult: SharedFlow<ContactToggleResult> = _contactToggleResult.asSharedFlow()
// Reply state - tracks which message is being replied to
private val _pendingReplyTo = MutableStateFlow<com.lxmf.messenger.ui.model.ReplyPreviewUi?>(null)
val pendingReplyTo: StateFlow<com.lxmf.messenger.ui.model.ReplyPreviewUi?> = _pendingReplyTo.asStateFlow()
/**
* Set a message to reply to. Called when user swipes on a message or selects "Reply".
* Loads the reply preview data from the database asynchronously.
*
* @param messageId The ID of the message to reply to
*/
fun setReplyTo(messageId: String) {
viewModelScope.launch {
try {
val replyPreview = conversationRepository.getReplyPreview(messageId, currentPeerName)
if (replyPreview != null) {
_pendingReplyTo.value = com.lxmf.messenger.ui.model.ReplyPreviewUi(
messageId = replyPreview.messageId,
senderName = replyPreview.senderName,
contentPreview = replyPreview.contentPreview,
hasImage = replyPreview.hasImage,
hasFileAttachment = replyPreview.hasFileAttachment,
firstFileName = replyPreview.firstFileName,
)
Log.d(TAG, "Set pending reply to message ${messageId.take(16)}")
} else {
Log.w(TAG, "Could not find message $messageId for reply preview")
}
} catch (e: Exception) {
Log.e(TAG, "Error loading reply preview for $messageId", e)
}
}
}
/**
* Clear the pending reply. Called when user cancels reply or after message is sent.
*/
fun clearReplyTo() {
_pendingReplyTo.value = null
Log.d(TAG, "Cleared pending reply")
}
/**
* Toggle contact status for the current conversation.
* If the peer is already a contact, removes them. Otherwise, adds them.
@@ -489,6 +534,9 @@ class MessagingViewModel
"files=${fileAttachments.size}, method=$deliveryMethod, tryPropOnFail=$tryPropOnFail)...",
)
// Get pending reply ID if replying to a message
val replyToId = _pendingReplyTo.value?.messageId
val result =
reticulumProtocol.sendLxmfMessageWithMethod(
destinationHash = destHashBytes,
@@ -499,10 +547,13 @@ class MessagingViewModel
imageData = imageData,
imageFormat = imageFormat,
fileAttachments = fileAttachmentPairs.ifEmpty { null },
replyToMessageId = replyToId,
)
result.onSuccess { receipt ->
handleSendSuccess(receipt, sanitized, destinationHash, imageData, imageFormat, fileAttachments, deliveryMethodString)
// Clear pending reply after successful send
handleSendSuccess(receipt, sanitized, destinationHash, imageData, imageFormat, fileAttachments, deliveryMethodString, replyToId)
clearReplyTo()
}.onFailure { error ->
handleSendFailure(error, sanitized, destinationHash, deliveryMethodString)
}
@@ -512,6 +563,7 @@ class MessagingViewModel
}
}
@Suppress("LongParameterList") // Refactoring to data class would add unnecessary complexity
private suspend fun handleSendSuccess(
receipt: com.lxmf.messenger.reticulum.protocol.MessageReceipt,
sanitized: String,
@@ -520,9 +572,10 @@ class MessagingViewModel
imageFormat: String?,
fileAttachments: List<FileAttachment>,
deliveryMethodString: String,
replyToMessageId: String? = null,
) {
Log.d(TAG, "Message sent successfully")
val fieldsJson = buildFieldsJson(imageData, imageFormat, fileAttachments)
Log.d(TAG, "Message sent successfully${if (replyToMessageId != null) " (reply to ${replyToMessageId.take(16)})" else ""}")
val fieldsJson = buildFieldsJson(imageData, imageFormat, fileAttachments, replyToMessageId)
val actualDestHash = resolveActualDestHash(receipt, destinationHash)
Log.d(TAG, "Original dest hash: $destinationHash, Actual LXMF dest hash: $actualDestHash")
@@ -536,6 +589,7 @@ class MessagingViewModel
status = "pending",
fieldsJson = fieldsJson,
deliveryMethod = deliveryMethodString,
replyToMessageId = replyToMessageId,
)
clearSelectedImage()
clearFileAttachments()
@@ -755,6 +809,55 @@ class MessagingViewModel
}
}
/**
* Load a reply preview asynchronously.
*
* Called by the UI when a message has replyToMessageId but no cached replyPreview.
* Loads the preview data on IO thread, caches it, and updates replyPreviewCache to
* trigger recomposition so the UI can display the reply preview.
*
* @param messageId The message ID that has a reply (used as cache key)
* @param replyToMessageId The ID of the message being replied to
*/
fun loadReplyPreviewAsync(
messageId: String,
replyToMessageId: String,
) {
// Skip if already loaded/loading
if (_replyPreviewCache.value.containsKey(messageId)) {
return
}
viewModelScope.launch {
try {
val replyPreview = conversationRepository.getReplyPreview(replyToMessageId, currentPeerName)
if (replyPreview != null) {
val uiPreview = com.lxmf.messenger.ui.model.ReplyPreviewUi(
messageId = replyPreview.messageId,
senderName = replyPreview.senderName,
contentPreview = replyPreview.contentPreview,
hasImage = replyPreview.hasImage,
hasFileAttachment = replyPreview.hasFileAttachment,
firstFileName = replyPreview.firstFileName,
)
_replyPreviewCache.update { it + (messageId to uiPreview) }
Log.d(TAG, "Loaded reply preview for message ${messageId.take(16)}")
} else {
// Mark as loaded with a "deleted message" placeholder
val deletedPreview = com.lxmf.messenger.ui.model.ReplyPreviewUi(
messageId = replyToMessageId,
senderName = "",
contentPreview = "Message deleted",
)
_replyPreviewCache.update { it + (messageId to deletedPreview) }
Log.d(TAG, "Reply target message not found: ${replyToMessageId.take(16)}")
}
} catch (e: Exception) {
Log.e(TAG, "Error loading reply preview for $messageId", e)
}
}
}
/**
* Load an image attachment asynchronously.
*
@@ -875,6 +978,7 @@ class MessagingViewModel
tryPropagationOnFail = tryPropOnFail,
imageData = imageData,
imageFormat = imageFormat,
replyToMessageId = failedMessage.replyToMessageId, // Preserve reply on retry
)
result.onSuccess { receipt ->
@@ -983,11 +1087,13 @@ private fun buildFieldsJson(
imageData: ByteArray?,
imageFormat: String?,
fileAttachments: List<FileAttachment> = emptyList(),
replyToMessageId: String? = null,
): String? {
val hasImage = imageData != null && imageFormat != null
val hasFiles = fileAttachments.isNotEmpty()
val hasReply = replyToMessageId != null
if (!hasImage && !hasFiles) return null
if (!hasImage && !hasFiles && !hasReply) return null
val json = org.json.JSONObject()
@@ -1010,6 +1116,15 @@ private fun buildFieldsJson(
json.put("5", attachmentsArray)
}
// Add app extensions field (Field 16) for replies and future features
if (hasReply) {
val appExtensions = org.json.JSONObject()
appExtensions.put("reply_to", replyToMessageId)
// Future: appExtensions.put("reactions", ...)
// Future: appExtensions.put("mentions", ...)
json.put("16", appExtensions)
}
return json.toString()
}

View File

@@ -2,6 +2,7 @@ package com.lxmf.messenger.test
import com.lxmf.messenger.data.repository.Announce
import com.lxmf.messenger.ui.model.MessageUi
import com.lxmf.messenger.ui.model.ReplyPreviewUi
/**
* Test fixtures for MessagingScreen UI tests.
@@ -271,4 +272,56 @@ object MessagingTestFixtures {
0x82.toByte(), // CRC
)
}
// ========== Reply Fixtures ==========
/**
* Creates a message that is a reply to another message.
* The reply preview is already loaded and attached.
*/
fun createMessageWithReply(
id: String = "msg_reply_001",
destinationHash: String = Constants.TEST_DESTINATION_HASH,
content: String = "This is a reply message",
timestamp: Long = System.currentTimeMillis(),
isFromMe: Boolean = true,
replyPreview: ReplyPreviewUi,
) = MessageUi(
id = id,
destinationHash = destinationHash,
content = content,
timestamp = timestamp,
isFromMe = isFromMe,
status = if (isFromMe) "sent" else "delivered",
decodedImage = null,
deliveryMethod = if (isFromMe) "direct" else null,
errorMessage = null,
replyToMessageId = replyPreview.messageId,
replyPreview = replyPreview,
)
/**
* Creates a message with a replyToMessageId but no cached preview.
* This is used to test async loading of reply previews.
*/
fun createMessageWithReplyId(
id: String = "msg_with_reply_id_001",
destinationHash: String = Constants.TEST_DESTINATION_HASH,
content: String = "Message with reply ID",
timestamp: Long = System.currentTimeMillis(),
isFromMe: Boolean = false,
replyToMessageId: String,
) = MessageUi(
id = id,
destinationHash = destinationHash,
content = content,
timestamp = timestamp,
isFromMe = isFromMe,
status = if (isFromMe) "sent" else "delivered",
decodedImage = null,
deliveryMethod = if (isFromMe) "direct" else null,
errorMessage = null,
replyToMessageId = replyToMessageId,
replyPreview = null, // Not yet loaded
)
}

View File

@@ -0,0 +1,442 @@
package com.lxmf.messenger.ui.components
import android.app.Application
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.lxmf.messenger.test.RegisterComponentActivityRule
import com.lxmf.messenger.ui.model.ReplyPreviewUi
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ReplyComponentsTest {
private val registerActivityRule = RegisterComponentActivityRule()
private val composeRule = createComposeRule()
@get:Rule
val ruleChain: RuleChain = RuleChain.outerRule(registerActivityRule).around(composeRule)
val composeTestRule get() = composeRule
// ========== ReplyPreviewBubble TESTS ==========
@Test
fun `ReplyPreviewBubble displays sender name`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Alice",
contentPreview = "Hello there!",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble displays content preview`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Bob",
contentPreview = "This is a reply preview text",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithText("This is a reply preview text").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble displays image icon when hasImage is true`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Charlie",
contentPreview = "",
hasImage = true,
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithContentDescription("Image").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble displays file icon when hasFileAttachment is true`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "David",
contentPreview = "",
hasFileAttachment = true,
firstFileName = "document.pdf",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithContentDescription("File").assertIsDisplayed()
composeTestRule.onNodeWithText("document.pdf").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble calls onClick when tapped`() {
var clicked = false
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Eve",
contentPreview = "Click me",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = { clicked = true },
)
}
}
composeTestRule.onNodeWithText("Eve").performClick()
assertTrue("onClick should be called", clicked)
}
@Test
fun `ReplyPreviewBubble displays Message placeholder for empty content`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Frank",
contentPreview = "",
hasImage = false,
hasFileAttachment = false,
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithText("Message").assertIsDisplayed()
}
// ========== ReplyInputBar TESTS ==========
@Test
fun `ReplyInputBar displays replying to sender name`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "George",
contentPreview = "Original message",
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
composeTestRule.onNodeWithText("Replying to George").assertIsDisplayed()
}
@Test
fun `ReplyInputBar displays content preview`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Hannah",
contentPreview = "This is the original message content",
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
composeTestRule.onNodeWithText("This is the original message content").assertIsDisplayed()
}
@Test
fun `ReplyInputBar calls onCancelReply when close button tapped`() {
var cancelled = false
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Ivan",
contentPreview = "Some content",
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = { cancelled = true },
)
}
}
composeTestRule.onNodeWithContentDescription("Cancel reply").performClick()
assertTrue("onCancelReply should be called", cancelled)
}
@Test
fun `ReplyInputBar displays Photo text for image attachment`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Julia",
contentPreview = "",
hasImage = true,
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
composeTestRule.onNodeWithText("Photo").assertIsDisplayed()
}
@Test
fun `ReplyInputBar displays filename for file attachment`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Kate",
contentPreview = "",
hasFileAttachment = true,
firstFileName = "report.xlsx",
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
composeTestRule.onNodeWithText("report.xlsx").assertIsDisplayed()
}
// ========== SwipeableMessageBubble TESTS ==========
@Test
fun `SwipeableMessageBubble displays content`() {
composeTestRule.setContent {
MaterialTheme {
SwipeableMessageBubble(
isFromMe = false,
onReply = {},
) {
Text("Test message content")
}
}
}
composeTestRule.onNodeWithText("Test message content").assertIsDisplayed()
}
@Test
fun `SwipeableMessageBubble displays for sent message`() {
composeTestRule.setContent {
MaterialTheme {
SwipeableMessageBubble(
isFromMe = true,
onReply = {},
) {
Text("Sent message")
}
}
}
composeTestRule.onNodeWithText("Sent message").assertIsDisplayed()
}
@Test
fun `SwipeableMessageBubble displays for received message`() {
composeTestRule.setContent {
MaterialTheme {
SwipeableMessageBubble(
isFromMe = false,
onReply = {},
) {
Text("Received message")
}
}
}
composeTestRule.onNodeWithText("Received message").assertIsDisplayed()
}
// ========== ReplyPreviewBubble Edge Cases ==========
@Test
fun `ReplyPreviewBubble displays for isFromMe true`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-789",
senderName = "You",
contentPreview = "Your original message",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = true,
onClick = {},
)
}
}
composeTestRule.onNodeWithText("You").assertIsDisplayed()
composeTestRule.onNodeWithText("Your original message").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble displays both image and content`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Lisa",
contentPreview = "Check out this photo!",
hasImage = true,
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithContentDescription("Image").assertIsDisplayed()
composeTestRule.onNodeWithText("Check out this photo!").assertIsDisplayed()
}
@Test
fun `ReplyPreviewBubble displays both file and content`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-123",
senderName = "Mike",
contentPreview = "Here's the document",
hasFileAttachment = true,
firstFileName = "invoice.pdf",
)
composeTestRule.setContent {
MaterialTheme {
ReplyPreviewBubble(
replyPreview = replyPreview,
isFromMe = false,
onClick = {},
)
}
}
composeTestRule.onNodeWithContentDescription("File").assertIsDisplayed()
composeTestRule.onNodeWithText("invoice.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("Here's the document").assertIsDisplayed()
}
@Test
fun `ReplyInputBar displays content preview when no attachments`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Nancy",
contentPreview = "Just a text message",
hasImage = false,
hasFileAttachment = false,
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
composeTestRule.onNodeWithText("Replying to Nancy").assertIsDisplayed()
composeTestRule.onNodeWithText("Just a text message").assertIsDisplayed()
}
@Test
fun `ReplyInputBar hides file icon when no filename`() {
val replyPreview = ReplyPreviewUi(
messageId = "msg-456",
senderName = "Oscar",
contentPreview = "Some text",
hasFileAttachment = true,
firstFileName = null, // No filename
)
composeTestRule.setContent {
MaterialTheme {
ReplyInputBar(
replyPreview = replyPreview,
onCancelReply = {},
)
}
}
// Should show content preview instead when no filename
composeTestRule.onNodeWithText("Some text").assertIsDisplayed()
}
}

View File

@@ -349,6 +349,179 @@ class MessageMapperTest {
// Should handle the path correctly - no exception means success
}
// ========== REPLY (FIELD 16) TESTS ==========
@Test
fun `toMessageUi maps replyToMessageId from Message data class`() {
val message = createMessage(
TestMessageConfig(
replyToMessageId = "original-message-hash-12345",
),
)
val result = message.toMessageUi()
assertEquals("original-message-hash-12345", result.replyToMessageId)
}
@Test
fun `toMessageUi extracts replyToMessageId from field 16 when not in data class`() {
// Field 16 contains reply_to as part of app extensions dict
val fieldsJson = """{"16": {"reply_to": "f4a3b2c1d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2"}}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertEquals(
"f4a3b2c1d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2",
result.replyToMessageId,
)
}
@Test
fun `toMessageUi prefers replyToMessageId from data class over field 16`() {
// Both data class and field 16 have reply info - prefer data class
val fieldsJson = """{"16": {"reply_to": "from_field_16"}}"""
val message = createMessage(
TestMessageConfig(
fieldsJson = fieldsJson,
replyToMessageId = "from_data_class",
),
)
val result = message.toMessageUi()
assertEquals("from_data_class", result.replyToMessageId)
}
@Test
fun `toMessageUi returns null replyToMessageId when field 16 is missing`() {
val message = createMessage(TestMessageConfig(fieldsJson = """{"6": "image_hex"}"""))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi returns null replyToMessageId when field 16 has no reply_to key`() {
// Field 16 exists but doesn't have reply_to key (maybe only reactions)
val fieldsJson = """{"16": {"reactions": {"👍": ["user1"]}}}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi returns null replyToMessageId when reply_to is empty`() {
val fieldsJson = """{"16": {"reply_to": ""}}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi handles field 16 as non-object type gracefully`() {
// Field 16 is a string instead of object
val fieldsJson = """{"16": "not an object"}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi handles field 16 as number gracefully`() {
val fieldsJson = """{"16": 12345}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi handles field 16 with null reply_to value`() {
val fieldsJson = """{"16": {"reply_to": null}}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
@Test
fun `toMessageUi parses reply with multiple field 16 properties`() {
// Future-proof: field 16 may have reactions, mentions, etc alongside reply_to
val fieldsJson = """{
"16": {
"reply_to": "message_hash_123",
"reactions": {"👍": ["user1"]},
"mentions": ["user2", "user3"]
}
}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertEquals("message_hash_123", result.replyToMessageId)
}
@Test
fun `toMessageUi sets replyPreview to null initially`() {
val fieldsJson = """{"16": {"reply_to": "some_message_id"}}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
// replyPreview is loaded asynchronously, so it should be null from mapper
assertNull(result.replyPreview)
}
@Test
fun `toMessageUi handles message with reply and image attachment`() {
val fieldsJson = """{
"6": "ffd8ffe0",
"16": {"reply_to": "original_msg_id"}
}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertTrue(result.hasImageAttachment)
assertEquals("original_msg_id", result.replyToMessageId)
}
@Test
fun `toMessageUi handles message with reply and file attachments`() {
val fieldsJson = """{
"5": [{"filename": "doc.pdf", "data": "0102", "size": 100}],
"16": {"reply_to": "original_msg_id"}
}"""
val message = createMessage(TestMessageConfig(fieldsJson = fieldsJson))
val result = message.toMessageUi()
assertTrue(result.hasFileAttachments)
assertEquals(1, result.fileAttachments.size)
assertEquals("original_msg_id", result.replyToMessageId)
}
@Test
fun `toMessageUi handles malformed JSON in field 16 gracefully`() {
// This shouldn't happen in practice, but test edge case
val message = createMessage(TestMessageConfig(fieldsJson = "not valid json"))
val result = message.toMessageUi()
assertNull(result.replyToMessageId)
}
/**
* Configuration class for creating test messages.
*/
@@ -362,6 +535,7 @@ class MessageMapperTest {
val fieldsJson: String? = null,
val deliveryMethod: String? = null,
val errorMessage: String? = null,
val replyToMessageId: String? = null,
)
private fun createMessage(config: TestMessageConfig = TestMessageConfig()): Message =
@@ -375,6 +549,7 @@ class MessageMapperTest {
fieldsJson = config.fieldsJson,
deliveryMethod = config.deliveryMethod,
errorMessage = config.errorMessage,
replyToMessageId = config.replyToMessageId,
)
private fun createTestBitmap() = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888).asImageBitmap()

View File

@@ -13,6 +13,7 @@ import androidx.paging.PagingData
import com.lxmf.messenger.test.MessagingTestFixtures
import com.lxmf.messenger.test.RegisterComponentActivityRule
import com.lxmf.messenger.ui.model.LocationSharingState
import com.lxmf.messenger.ui.model.ReplyPreviewUi
import com.lxmf.messenger.viewmodel.ContactToggleResult
import com.lxmf.messenger.viewmodel.MessagingViewModel
import io.mockk.every
@@ -70,6 +71,9 @@ class MessagingScreenTest {
// Location sharing mocks
every { mockViewModel.contacts } returns MutableStateFlow(emptyList())
every { mockViewModel.locationSharingState } returns MutableStateFlow(LocationSharingState.NONE)
// Reply mocks
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(null)
every { mockViewModel.replyPreviewCache } returns MutableStateFlow(emptyMap())
}
// ========== Empty State Tests ==========
@@ -884,4 +888,291 @@ class MessagingScreenTest {
}
composeTestRule.waitForIdle()
}
// ========== Reply Functionality Tests ==========
@Test
fun replyInputBar_displayed_whenPendingReplyIsSet() {
// Given - a pending reply is set
val replyPreview = ReplyPreviewUi(
messageId = "reply-msg-123",
senderName = "Alice",
contentPreview = "Original message content",
)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(replyPreview)
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - ReplyInputBar should be displayed
composeTestRule.onNodeWithText("Replying to Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("Original message content").assertIsDisplayed()
}
@Test
fun replyInputBar_notDisplayed_whenNoPendingReply() {
// Given - no pending reply (default state from setup)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(null)
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - ReplyInputBar should not be displayed
composeTestRule.onNodeWithText("Replying to", substring = true).assertDoesNotExist()
}
@Test
fun replyInputBar_cancelButton_callsClearReplyTo() {
// Given - a pending reply is set
val replyPreview = ReplyPreviewUi(
messageId = "reply-msg-123",
senderName = "Bob",
contentPreview = "Some content",
)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(replyPreview)
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// When - tap cancel button
composeTestRule.onNodeWithContentDescription("Cancel reply").performClick()
// Then - clearReplyTo should be called
verify { mockViewModel.clearReplyTo() }
}
@Test
fun replyInputBar_withImageReply_displaysPhotoIndicator() {
// Given - a pending reply to an image message
val replyPreview = ReplyPreviewUi(
messageId = "img-reply-123",
senderName = "Charlie",
contentPreview = "",
hasImage = true,
)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(replyPreview)
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - should show Photo indicator
composeTestRule.onNodeWithText("Photo").assertIsDisplayed()
}
@Test
fun replyInputBar_withFileReply_displaysFilename() {
// Given - a pending reply to a file message
val replyPreview = ReplyPreviewUi(
messageId = "file-reply-123",
senderName = "David",
contentPreview = "",
hasFileAttachment = true,
firstFileName = "document.pdf",
)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(replyPreview)
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - should show filename
composeTestRule.onNodeWithText("document.pdf").assertIsDisplayed()
}
@Test
fun inputBar_withPendingReply_sendButtonEnabled() {
// Given - pending reply with text entered
val replyPreview = ReplyPreviewUi(
messageId = "reply-msg",
senderName = "Eve",
contentPreview = "Original",
)
every { mockViewModel.pendingReplyTo } returns MutableStateFlow(replyPreview)
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
// When - enter text
composeTestRule.onNodeWithText("Type a message...").performTextInput("Reply message")
// Then - send button should be enabled
composeTestRule.onNodeWithContentDescription("Send message").assertIsEnabled()
}
@Test
fun messageWithReply_displaysReplyPreviewBubble() {
// Given - a message that is a reply to another message
val replyPreview = ReplyPreviewUi(
messageId = "original-msg",
senderName = "Frank",
contentPreview = "This is the original message",
)
val messageWithReply = MessagingTestFixtures.createMessageWithReply(
content = "This is my reply",
replyPreview = replyPreview,
)
every { mockViewModel.messages } returns flowOf(PagingData.from(listOf(messageWithReply)))
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - reply preview should be displayed
composeTestRule.onNodeWithText("Frank").assertIsDisplayed()
composeTestRule.onNodeWithText("This is the original message").assertIsDisplayed()
composeTestRule.onNodeWithText("This is my reply").assertIsDisplayed()
}
@Test
fun messageWithReply_loadsReplyPreviewAsync_whenNotCached() {
// Given - a message with replyToMessageId but no cached preview
val messageWithReplyId = MessagingTestFixtures.createMessageWithReplyId(
id = "msg-with-reply",
replyToMessageId = "original-msg-id",
)
every { mockViewModel.messages } returns flowOf(PagingData.from(listOf(messageWithReplyId)))
every { mockViewModel.replyPreviewCache } returns MutableStateFlow(emptyMap())
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - should trigger async loading of reply preview
verify { mockViewModel.loadReplyPreviewAsync("msg-with-reply", "original-msg-id") }
}
@Test
fun messageWithReply_doesNotReload_whenCached() {
// Given - a message with replyToMessageId and cached preview
val messageId = "cached-reply-msg"
val replyToId = "original-msg"
val messageWithReplyId = MessagingTestFixtures.createMessageWithReplyId(
id = messageId,
replyToMessageId = replyToId,
)
val cachedPreview = ReplyPreviewUi(
messageId = replyToId,
senderName = "Grace",
contentPreview = "Cached content",
)
every { mockViewModel.messages } returns flowOf(PagingData.from(listOf(messageWithReplyId)))
every { mockViewModel.replyPreviewCache } returns MutableStateFlow(mapOf(messageId to cachedPreview))
// When
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Then - should NOT trigger async loading (already cached)
verify(exactly = 0) { mockViewModel.loadReplyPreviewAsync(any(), any()) }
// And should display cached preview
composeTestRule.onNodeWithText("Grace").assertIsDisplayed()
composeTestRule.onNodeWithText("Cached content").assertIsDisplayed()
}
@Test
fun replyPreviewCache_update_triggersRecomposition() {
// Given - message with reply that initially has no cache
val messageId = "recompose-test-msg"
val replyToId = "original-for-recompose"
val messageWithReplyId = MessagingTestFixtures.createMessageWithReplyId(
id = messageId,
replyToMessageId = replyToId,
)
val cacheFlow = MutableStateFlow<Map<String, ReplyPreviewUi>>(emptyMap())
every { mockViewModel.messages } returns flowOf(PagingData.from(listOf(messageWithReplyId)))
every { mockViewModel.replyPreviewCache } returns cacheFlow
// When - render initially
composeTestRule.setContent {
MessagingScreen(
destinationHash = MessagingTestFixtures.Constants.TEST_DESTINATION_HASH,
peerName = MessagingTestFixtures.Constants.TEST_PEER_NAME,
onBackClick = {},
viewModel = mockViewModel,
)
}
composeTestRule.waitForIdle()
// Initial load should be triggered
verify { mockViewModel.loadReplyPreviewAsync(messageId, replyToId) }
// Then - update cache (simulating async load completion)
val loadedPreview = ReplyPreviewUi(
messageId = replyToId,
senderName = "Henry",
contentPreview = "Loaded content",
)
cacheFlow.value = mapOf(messageId to loadedPreview)
composeTestRule.waitForIdle()
// Should display loaded preview
composeTestRule.onNodeWithText("Henry").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,524 @@
package com.lxmf.messenger.util
import android.app.Application
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Unit tests for ThemeColorGenerator using Robolectric.
* Tests color scheme generation, HSL manipulation, and accessibility helpers.
*
* Migrated from instrumented test - ColorUtils is supported by Robolectric.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ThemeColorGeneratorTest {
// ========== Color Scheme Generation ==========
@Test
fun generateColorScheme_createsValidMaterial3SchemeForLightMode() {
val seedColor = Color.Blue.toArgb()
val lightScheme = ThemeColorGenerator.generateColorScheme(seedColor, isDark = false)
// Verify all required color roles are present
assertNotNull(lightScheme.primary)
assertNotNull(lightScheme.onPrimary)
assertNotNull(lightScheme.primaryContainer)
assertNotNull(lightScheme.onPrimaryContainer)
assertNotNull(lightScheme.secondary)
assertNotNull(lightScheme.onSecondary)
assertNotNull(lightScheme.tertiary)
assertNotNull(lightScheme.onTertiary)
assertNotNull(lightScheme.error)
assertNotNull(lightScheme.background)
assertNotNull(lightScheme.surface)
}
@Test
fun generateColorScheme_createsValidMaterial3SchemeForDarkMode() {
val seedColor = Color.Red.toArgb()
val darkScheme = ThemeColorGenerator.generateColorScheme(seedColor, isDark = true)
// Verify all required color roles are present
assertNotNull(darkScheme.primary)
assertNotNull(darkScheme.onPrimary)
assertNotNull(darkScheme.primaryContainer)
assertNotNull(darkScheme.secondary)
assertNotNull(darkScheme.tertiary)
assertNotNull(darkScheme.error)
assertNotNull(darkScheme.background)
assertNotNull(darkScheme.surface)
}
@Test
fun generateColorScheme_adjustsColorsBasedOnDarkMode() {
val seedColor = Color.Green.toArgb()
val lightScheme = ThemeColorGenerator.generateColorScheme(seedColor, isDark = false)
val darkScheme = ThemeColorGenerator.generateColorScheme(seedColor, isDark = true)
// Dark and light schemes should have different primary colors
assertNotEquals(lightScheme.primary.toArgb(), darkScheme.primary.toArgb())
}
@Test
fun generateColorSchemes_returnsBothLightAndDarkSchemes() {
val seedColor = Color.Magenta
val (lightScheme, darkScheme) = ThemeColorGenerator.generateColorSchemes(seedColor)
assertNotNull(lightScheme)
assertNotNull(darkScheme)
assertNotEquals(lightScheme.primary, darkScheme.primary)
}
// ========== HSL Color Manipulation ==========
@Test
fun lightenColor_increasesLightness() {
val darkBlue = 0xFF000080.toInt() // Dark blue
val lighterBlue = ThemeColorGenerator.lightenColor(darkBlue, 0.2f)
// Lightened color should be different
assertNotEquals(darkBlue, lighterBlue)
}
@Test
fun darkenColor_decreasesLightness() {
val lightBlue = 0xFF8080FF.toInt() // Light blue
val darkerBlue = ThemeColorGenerator.darkenColor(lightBlue, 0.2f)
assertNotEquals(lightBlue, darkerBlue)
}
@Test
fun lightenColor_clampsToMaximumLightness() {
val color = 0xFF808080.toInt()
val maxLightened = ThemeColorGenerator.lightenColor(color, 2.0f) // Extreme amount
// Should not throw exception and should return valid color
assertNotNull(maxLightened)
}
@Test
fun darkenColor_clampsToMinimumLightness() {
val color = 0xFF808080.toInt()
val maxDarkened = ThemeColorGenerator.darkenColor(color, 2.0f) // Extreme amount
// Should not throw exception and should return valid color
assertNotNull(maxDarkened)
}
// ========== Harmonized Colors ==========
@Test
fun suggestComplementaryColors_returnsThreeDifferentColors() {
val seedColor = Color.Blue.toArgb()
val (primary, secondary, tertiary) = ThemeColorGenerator.suggestComplementaryColors(seedColor)
// All three should be different
assertNotEquals(primary, secondary)
assertNotEquals(secondary, tertiary)
assertNotEquals(primary, tertiary)
// Primary should be the seed color
assertEquals(seedColor, primary)
}
// ========== Contrast Checking ==========
@Test
fun hasSufficientContrast_detectsGoodContrastBetweenBlackAndWhite() {
val white = Color.White.toArgb()
val black = Color.Black.toArgb()
assertTrue(
"White on black should have sufficient contrast",
ThemeColorGenerator.hasSufficientContrast(white, black, minContrast = 4.5),
)
}
@Test
fun hasSufficientContrast_detectsPoorContrastBetweenSimilarColors() {
val lightGray = 0xFFCCCCCC.toInt()
val white = Color.White.toArgb()
assertFalse(
"Light gray on white should not have sufficient contrast",
ThemeColorGenerator.hasSufficientContrast(lightGray, white, minContrast = 4.5),
)
}
@Test
fun hasSufficientContrast_usesWCAGAAStandardByDefault() {
val white = Color.White.toArgb()
val black = Color.Black.toArgb()
// Default should be 4.5:1 (WCAG AA)
assertTrue(ThemeColorGenerator.hasSufficientContrast(white, black))
}
@Test
fun calculateContrastRatio_returnsHighRatioForBlackAndWhite() {
val white = Color.White.toArgb()
val black = Color.Black.toArgb()
val ratio = ThemeColorGenerator.calculateContrastRatio(white, black)
// Black and white should have 21:1 contrast ratio
assertTrue("Contrast ratio should be very high (got $ratio)", ratio > 20.0)
}
@Test
fun calculateContrastRatio_returnsLowRatioForSimilarColors() {
val gray1 = 0xFF808080.toInt()
val gray2 = 0xFF888888.toInt()
val ratio = ThemeColorGenerator.calculateContrastRatio(gray1, gray2)
assertTrue("Contrast ratio should be low for similar colors (got $ratio)", ratio < 2.0)
}
@Test
fun getContrastingColor_returnsBlackForLightBackgrounds() {
val lightBackground = Color.White.toArgb()
val textColor = ThemeColorGenerator.getContrastingColor(lightBackground)
assertEquals("Should return black for light background", 0xFF000000.toInt(), textColor)
}
@Test
fun getContrastingColor_returnsWhiteForDarkBackgrounds() {
val darkBackground = Color.Black.toArgb()
val textColor = ThemeColorGenerator.getContrastingColor(darkBackground)
assertEquals("Should return white for dark background", 0xFFFFFFFF.toInt(), textColor)
}
@Test
fun getContrastingColor_forceDarkReturnsDark() {
val lightBackground = Color.White.toArgb()
val darkBackground = Color.Black.toArgb()
val textOnLight = ThemeColorGenerator.getContrastingColor(lightBackground, forceDark = true)
val textOnDark = ThemeColorGenerator.getContrastingColor(darkBackground, forceDark = true)
// When forceDark is true, should always return black
assertEquals(0xFF000000.toInt(), textOnLight)
assertEquals(0xFF000000.toInt(), textOnDark)
}
// ========== Hex Conversion ==========
@Test
fun hexToArgb_parsesSixDigitHexCorrectly() {
val hex = "#FF5733"
val argb = ThemeColorGenerator.hexToArgb(hex)
assertNotNull(argb)
assertEquals(0xFFFF5733.toInt(), argb)
}
@Test
fun hexToArgb_parsesSixDigitHexWithoutHash() {
val hex = "FF5733"
val argb = ThemeColorGenerator.hexToArgb(hex)
assertNotNull(argb)
assertEquals(0xFFFF5733.toInt(), argb)
}
@Test
fun hexToArgb_parsesThreeDigitHexCorrectly() {
val hex = "#F73"
val argb = ThemeColorGenerator.hexToArgb(hex)
assertNotNull(argb)
// #F73 should expand to #FF7733
assertEquals(0xFFFF7733.toInt(), argb)
}
@Test
fun hexToArgb_parsesEightDigitHexWithAlpha() {
val hex = "#80FF5733"
val argb = ThemeColorGenerator.hexToArgb(hex)
assertNotNull(argb)
assertEquals(0x80FF5733.toInt(), argb)
}
@Test
fun hexToArgb_returnsNullForInvalidHex() {
val invalidHex = "ZZZZZZ"
val argb = ThemeColorGenerator.hexToArgb(invalidHex)
assertNull("Invalid hex should return null", argb)
}
@Test
fun hexToArgb_returnsNullForWrongLength() {
val wrongLength = "#FF573" // 5 characters
val argb = ThemeColorGenerator.hexToArgb(wrongLength)
assertNull("Wrong length hex should return null", argb)
}
@Test
fun argbToHex_convertsColorCorrectlyWithoutAlpha() {
val argb = 0xFFFF5733.toInt()
val hex = ThemeColorGenerator.argbToHex(argb, includeAlpha = false)
assertEquals("#FF5733", hex)
}
@Test
fun argbToHex_convertsColorCorrectlyWithAlpha() {
val argb = 0x80FF5733.toInt()
val hex = ThemeColorGenerator.argbToHex(argb, includeAlpha = true)
assertEquals("#80FF5733", hex)
}
@Test
fun hexConversion_roundtripPreservesColor() {
val originalColor = 0xFFABCDEF.toInt()
val hex = ThemeColorGenerator.argbToHex(originalColor, includeAlpha = false)
val convertedBack = ThemeColorGenerator.hexToArgb(hex)
assertEquals(originalColor, convertedBack)
}
// ========== Tonal Palette ==========
@Test
fun generateTonalPalette_createsTenTones() {
val seedColor = Color.Blue.toArgb()
val palette = ThemeColorGenerator.generateTonalPalette(seedColor)
assertEquals("Should generate 10 tones", 10, palette.size)
}
@Test
fun generateTonalPalette_allColorsAreUnique() {
val seedColor = Color.Green.toArgb()
val palette = ThemeColorGenerator.generateTonalPalette(seedColor)
val uniqueColors = palette.toSet()
assertEquals("All tones should be unique", palette.size, uniqueColors.size)
}
@Test
fun generateTonalPalette_orderedFromLightToDark() {
val seedColor = Color.Blue.toArgb()
val palette = ThemeColorGenerator.generateTonalPalette(seedColor)
// Each subsequent color should be darker (lower luminance)
// We can verify by checking the HSL lightness values
// First color should be lightest, last should be darkest
assertTrue(palette.size == 10)
}
// ========== Edge Cases ==========
@Test
fun generateColorScheme_handlesPureBlackSeedColor() {
val black = Color.Black.toArgb()
val scheme = ThemeColorGenerator.generateColorScheme(black, isDark = false)
// Should not crash and should produce valid scheme
assertNotNull(scheme.primary)
assertNotNull(scheme.secondary)
assertNotNull(scheme.tertiary)
}
@Test
fun generateColorScheme_handlesPureWhiteSeedColor() {
val white = Color.White.toArgb()
val scheme = ThemeColorGenerator.generateColorScheme(white, isDark = true)
// Should not crash and should produce valid scheme
assertNotNull(scheme.primary)
assertNotNull(scheme.secondary)
assertNotNull(scheme.tertiary)
}
@Test
fun generateColorScheme_handlesSaturatedColors() {
val pureRed = Color.Red.toArgb()
val scheme = ThemeColorGenerator.generateColorScheme(pureRed, isDark = false)
assertNotNull(scheme.primary)
assertNotNull(scheme.secondary)
assertNotNull(scheme.tertiary)
}
@Test
fun generateColorScheme_handlesGrayscaleColors() {
val gray = 0xFF808080.toInt()
val scheme = ThemeColorGenerator.generateColorScheme(gray, isDark = false)
// Should still generate valid complementary colors
assertNotNull(scheme.primary)
assertNotNull(scheme.secondary)
assertNotNull(scheme.tertiary)
}
// ========== Three-Seed Color Scheme Tests ==========
@Test
fun generateColorScheme_withThreeSeeds_createsValidLightScheme() {
val redSeed = Color.Red.toArgb()
val greenSeed = Color.Green.toArgb()
val blueSeed = Color.Blue.toArgb()
val colorScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = false,
)
// Verify all required color roles are present and not null
assertNotNull(colorScheme.primary)
assertNotNull(colorScheme.onPrimary)
assertNotNull(colorScheme.primaryContainer)
assertNotNull(colorScheme.onPrimaryContainer)
assertNotNull(colorScheme.secondary)
assertNotNull(colorScheme.onSecondary)
assertNotNull(colorScheme.secondaryContainer)
assertNotNull(colorScheme.onSecondaryContainer)
assertNotNull(colorScheme.tertiary)
assertNotNull(colorScheme.onTertiary)
assertNotNull(colorScheme.tertiaryContainer)
assertNotNull(colorScheme.onTertiaryContainer)
assertNotNull(colorScheme.error)
assertNotNull(colorScheme.onError)
assertNotNull(colorScheme.background)
assertNotNull(colorScheme.onBackground)
assertNotNull(colorScheme.surface)
assertNotNull(colorScheme.onSurface)
}
@Test
fun generateColorScheme_withThreeSeeds_createsValidDarkScheme() {
val redSeed = Color.Red.toArgb()
val greenSeed = Color.Green.toArgb()
val blueSeed = Color.Blue.toArgb()
val colorScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = true,
)
// Verify all required color roles are present
assertNotNull(colorScheme.primary)
assertNotNull(colorScheme.secondary)
assertNotNull(colorScheme.tertiary)
assertNotNull(colorScheme.background)
assertNotNull(colorScheme.surface)
}
@Test
fun generateColorScheme_withThreeSeeds_usesDifferentSeedsForEachPalette() {
val redSeed = Color.Red.toArgb()
val greenSeed = Color.Green.toArgb()
val blueSeed = Color.Blue.toArgb()
val colorScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = false,
)
// Primary, secondary, tertiary should be visually different
val primary = colorScheme.primary.toArgb()
val secondary = colorScheme.secondary.toArgb()
val tertiary = colorScheme.tertiary.toArgb()
assertNotEquals(primary, secondary)
assertNotEquals(primary, tertiary)
assertNotEquals(secondary, tertiary)
}
@Test
fun generateColorScheme_withThreeIdenticalSeeds_stillProducesValidScheme() {
val purpleSeed = 0xFF6200EE.toInt()
val colorScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = purpleSeed,
secondarySeed = purpleSeed,
tertiarySeed = purpleSeed,
isDark = false,
)
// Should not crash and should return valid ColorScheme
assertNotNull(colorScheme)
assertNotNull(colorScheme.primary)
assertNotNull(colorScheme.secondary)
assertNotNull(colorScheme.tertiary)
}
@Test
fun generateColorScheme_threeSeed_adjustsLightnessForDarkMode() {
val redSeed = Color.Red.toArgb()
val greenSeed = Color.Green.toArgb()
val blueSeed = Color.Blue.toArgb()
val lightScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = false,
)
val darkScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = true,
)
// Dark mode colors should be different from light mode colors
val lightPrimary = lightScheme.primary.toArgb()
val darkPrimary = darkScheme.primary.toArgb()
assertNotEquals(lightPrimary, darkPrimary)
}
@Test
fun generateColorScheme_threeSeed_createsProperContainers() {
val redSeed = Color.Red.toArgb()
val greenSeed = Color.Green.toArgb()
val blueSeed = Color.Blue.toArgb()
val colorScheme = ThemeColorGenerator.generateColorScheme(
primarySeed = redSeed,
secondarySeed = greenSeed,
tertiarySeed = blueSeed,
isDark = false,
)
// Containers should exist and be different from main colors
val primary = colorScheme.primary.toArgb()
val primaryContainer = colorScheme.primaryContainer.toArgb()
val secondary = colorScheme.secondary.toArgb()
val secondaryContainer = colorScheme.secondaryContainer.toArgb()
val tertiary = colorScheme.tertiary.toArgb()
val tertiaryContainer = colorScheme.tertiaryContainer.toArgb()
// Containers should be lighter/darker versions (different from main colors)
assertNotEquals(primary, primaryContainer)
assertNotEquals(secondary, secondaryContainer)
assertNotEquals(tertiary, tertiaryContainer)
}
}

View File

@@ -42,6 +42,7 @@ import java.io.ByteArrayOutputStream
import com.lxmf.messenger.util.FileAttachment
import com.lxmf.messenger.util.FileUtils
import com.lxmf.messenger.data.repository.Message as DataMessage
import com.lxmf.messenger.data.repository.ReplyPreview
/**
* Unit tests for MessagingViewModel.
@@ -119,6 +120,9 @@ class MessagingViewModelTest {
// Default: no announce info
every { announceRepository.getAnnounceFlow(any()) } returns flowOf(null)
// Default: no reply preview
coEvery { conversationRepository.getReplyPreview(any(), any()) } returns null
}
@After
@@ -2328,4 +2332,450 @@ class MessagingViewModelTest {
tempDir.deleteRecursively()
}
// ========== REPLY FUNCTIONALITY TESTS ==========
@Test
fun `setReplyTo sets pending reply when message found`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock reply preview exists
val replyPreview = ReplyPreview(
messageId = "reply-msg-123",
senderName = "Alice",
contentPreview = "Hello there!",
hasImage = false,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("reply-msg-123", any()) } returns replyPreview
// Load conversation first
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Set reply to message
viewModel.setReplyTo("reply-msg-123")
advanceUntilIdle()
// Assert: pendingReplyTo is set
val pending = viewModel.pendingReplyTo.value
assertNotNull(pending)
assertEquals("reply-msg-123", pending!!.messageId)
assertEquals("Alice", pending.senderName)
assertEquals("Hello there!", pending.contentPreview)
}
@Test
fun `setReplyTo does not set pending reply when message not found`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock reply preview not found
coEvery { conversationRepository.getReplyPreview("unknown-msg", any()) } returns null
// Load conversation first
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Try to set reply to non-existent message
viewModel.setReplyTo("unknown-msg")
advanceUntilIdle()
// Assert: pendingReplyTo is NOT set
assertNull(viewModel.pendingReplyTo.value)
}
@Test
fun `clearReplyTo clears pending reply`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Set a pending reply first
val replyPreview = ReplyPreview(
messageId = "reply-msg-123",
senderName = "Alice",
contentPreview = "Hello there!",
hasImage = false,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("reply-msg-123", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
viewModel.setReplyTo("reply-msg-123")
advanceUntilIdle()
// Verify it's set
assertNotNull(viewModel.pendingReplyTo.value)
// Act: Clear the reply
viewModel.clearReplyTo()
advanceUntilIdle()
// Assert: pendingReplyTo is cleared
assertNull(viewModel.pendingReplyTo.value)
}
@Test
fun `pendingReplyTo initial state is null`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Assert: Initial state is null
assertNull(viewModel.pendingReplyTo.value)
}
@Test
fun `loadReplyPreviewAsync caches reply preview`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock reply preview exists
val replyPreview = ReplyPreview(
messageId = "original-msg-456",
senderName = "Bob",
contentPreview = "Original message content",
hasImage = true,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("original-msg-456", any()) } returns replyPreview
// Load conversation first
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Load reply preview for a message
viewModel.loadReplyPreviewAsync("current-msg-789", "original-msg-456")
advanceUntilIdle()
// Assert: Reply preview is cached
val cache = viewModel.replyPreviewCache.value
assertTrue(cache.containsKey("current-msg-789"))
val cachedPreview = cache["current-msg-789"]
assertNotNull(cachedPreview)
assertEquals("original-msg-456", cachedPreview!!.messageId)
assertEquals("Bob", cachedPreview.senderName)
assertTrue(cachedPreview.hasImage)
}
@Test
fun `loadReplyPreviewAsync does not reload if already cached`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock reply preview exists
val replyPreview = ReplyPreview(
messageId = "original-msg",
senderName = "Alice",
contentPreview = "Hello",
hasImage = false,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("original-msg", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Load once
viewModel.loadReplyPreviewAsync("msg-1", "original-msg")
advanceUntilIdle()
// Verify it was loaded
coVerify(exactly = 1) { conversationRepository.getReplyPreview("original-msg", any()) }
// Try to load again
viewModel.loadReplyPreviewAsync("msg-1", "original-msg")
advanceUntilIdle()
// Assert: Repository was NOT called again (cached)
coVerify(exactly = 1) { conversationRepository.getReplyPreview("original-msg", any()) }
}
@Test
fun `loadReplyPreviewAsync handles deleted message gracefully`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Reply target message not found
coEvery { conversationRepository.getReplyPreview("deleted-msg", any()) } returns null
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Load reply preview for deleted message
viewModel.loadReplyPreviewAsync("current-msg", "deleted-msg")
advanceUntilIdle()
// Assert: Cache contains a placeholder for deleted message
val cache = viewModel.replyPreviewCache.value
assertTrue(cache.containsKey("current-msg"))
val cachedPreview = cache["current-msg"]
assertNotNull(cachedPreview)
assertEquals("deleted-msg", cachedPreview!!.messageId)
assertEquals("Message deleted", cachedPreview.contentPreview)
}
@Test
fun `replyPreviewCache initial state is empty`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Assert: Initial state is empty map
assertTrue(viewModel.replyPreviewCache.value.isEmpty())
}
@Test
fun `sendMessage with pending reply includes replyToMessageId`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock successful send
val destHashBytes = testPeerHash.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val testReceipt = MessageReceipt(
messageHash = ByteArray(32) { it.toByte() },
timestamp = 3000L,
destinationHash = destHashBytes,
)
coEvery {
reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any())
} returns Result.success(testReceipt)
coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs
// Setup: Set a pending reply
val replyPreview = ReplyPreview(
messageId = "reply-to-this-msg",
senderName = "Alice",
contentPreview = "Original message",
hasImage = false,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("reply-to-this-msg", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
viewModel.setReplyTo("reply-to-this-msg")
advanceUntilIdle()
// Verify reply is set
assertNotNull(viewModel.pendingReplyTo.value)
// Act: Send message
viewModel.sendMessage(testPeerHash, "This is my reply")
advanceUntilIdle()
// Assert: Message saved with replyToMessageId
coVerify {
conversationRepository.saveMessage(
peerHash = testPeerHash,
peerName = testPeerName,
message = match { it.replyToMessageId == "reply-to-this-msg" },
peerPublicKey = null,
)
}
}
@Test
fun `sendMessage clears pending reply after successful send`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock successful send
val destHashBytes = testPeerHash.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val testReceipt = MessageReceipt(
messageHash = ByteArray(32) { it.toByte() },
timestamp = 3000L,
destinationHash = destHashBytes,
)
coEvery {
reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any())
} returns Result.success(testReceipt)
coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs
// Setup: Set a pending reply
val replyPreview = ReplyPreview(
messageId = "reply-to-this-msg",
senderName = "Alice",
contentPreview = "Original message",
hasImage = false,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("reply-to-this-msg", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
viewModel.setReplyTo("reply-to-this-msg")
advanceUntilIdle()
// Verify reply is set before send
assertNotNull(viewModel.pendingReplyTo.value)
// Act: Send message
viewModel.sendMessage(testPeerHash, "This is my reply")
advanceUntilIdle()
// Assert: Pending reply was cleared after successful send
assertNull(viewModel.pendingReplyTo.value)
}
@Test
fun `sendMessage without pending reply does not include replyToMessageId`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Mock successful send
val destHashBytes = testPeerHash.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val testReceipt = MessageReceipt(
messageHash = ByteArray(32) { it.toByte() },
timestamp = 3000L,
destinationHash = destHashBytes,
)
coEvery {
reticulumProtocol.sendLxmfMessageWithMethod(any(), any(), any(), any(), any(), any(), any(), any(), any())
} returns Result.success(testReceipt)
coEvery { conversationRepository.saveMessage(any(), any(), any(), any()) } just Runs
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// No pending reply set
// Act: Send message
viewModel.sendMessage(testPeerHash, "Regular message")
advanceUntilIdle()
// Assert: Message saved without replyToMessageId
coVerify {
conversationRepository.saveMessage(
peerHash = testPeerHash,
peerName = testPeerName,
message = match { it.replyToMessageId == null },
peerPublicKey = null,
)
}
}
@Test
fun `setReplyTo handles exception gracefully`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Repository throws exception
coEvery { conversationRepository.getReplyPreview(any(), any()) } throws RuntimeException("DB error")
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Try to set reply (should not crash)
viewModel.setReplyTo("some-msg")
advanceUntilIdle()
// Assert: No crash, pendingReplyTo remains null
assertNull(viewModel.pendingReplyTo.value)
}
@Test
fun `loadReplyPreviewAsync handles exception gracefully`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Repository throws exception
coEvery { conversationRepository.getReplyPreview(any(), any()) } throws RuntimeException("DB error")
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act: Try to load reply preview (should not crash)
viewModel.loadReplyPreviewAsync("current-msg", "reply-to-msg")
advanceUntilIdle()
// Assert: No crash, cache remains empty
assertTrue(viewModel.replyPreviewCache.value.isEmpty())
}
@Test
fun `setReplyTo with image attachment sets hasImage correctly`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Reply preview has image
val replyPreview = ReplyPreview(
messageId = "img-msg",
senderName = "Alice",
contentPreview = "Check out this photo",
hasImage = true,
hasFileAttachment = false,
firstFileName = null,
)
coEvery { conversationRepository.getReplyPreview("img-msg", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act
viewModel.setReplyTo("img-msg")
advanceUntilIdle()
// Assert
val pending = viewModel.pendingReplyTo.value
assertNotNull(pending)
assertTrue(pending!!.hasImage)
}
@Test
fun `setReplyTo with file attachment sets hasFileAttachment correctly`() =
runTest {
val viewModel = createTestViewModel()
advanceUntilIdle()
// Setup: Reply preview has file attachment
val replyPreview = ReplyPreview(
messageId = "file-msg",
senderName = "Bob",
contentPreview = "Here is the document",
hasImage = false,
hasFileAttachment = true,
firstFileName = "report.pdf",
)
coEvery { conversationRepository.getReplyPreview("file-msg", any()) } returns replyPreview
viewModel.loadMessages(testPeerHash, testPeerName)
advanceUntilIdle()
// Act
viewModel.setReplyTo("file-msg")
advanceUntilIdle()
// Assert
val pending = viewModel.pendingReplyTo.value
assertNotNull(pending)
assertTrue(pending!!.hasFileAttachment)
assertEquals("report.pdf", pending.firstFileName)
}
}

View File

@@ -30,7 +30,7 @@ import com.lxmf.messenger.data.db.entity.ReceivedLocationEntity
LocalIdentityEntity::class,
ReceivedLocationEntity::class,
],
version = 25,
version = 26,
exportSchema = false,
)
abstract class ColumbaDatabase : RoomDatabase() {

View File

@@ -163,4 +163,35 @@ interface MessageDao {
messageId: String,
identityHash: String,
)
/**
* Get lightweight reply preview data for a message.
* Returns minimal data needed to display a reply preview (sender, content preview, attachment info).
* Used when displaying a message that is replying to another message.
*/
@Query(
"""
SELECT id, content, isFromMe, fieldsJson, conversationHash
FROM messages
WHERE id = :messageId AND identityHash = :identityHash
LIMIT 1
""",
)
suspend fun getReplyPreviewData(
messageId: String,
identityHash: String,
): ReplyPreviewEntity?
}
/**
* Lightweight entity for reply preview data.
* Contains only the fields needed to display a reply preview, avoiding
* loading full message data with large attachment payloads.
*/
data class ReplyPreviewEntity(
val id: String,
val content: String,
val isFromMe: Boolean,
val fieldsJson: String?,
val conversationHash: String,
)

View File

@@ -27,6 +27,7 @@ import androidx.room.Index
Index("timestamp"), // For ordering by timestamp
Index("conversationHash", "identityHash", "timestamp"), // For queries with ordering
Index("conversationHash", "identityHash", "isFromMe", "isRead"), // For unread count queries
Index("replyToMessageId"), // For efficient reply lookups
], // Indexes for faster queries
)
data class MessageEntity(
@@ -46,4 +47,6 @@ data class MessageEntity(
val deliveryMethod: String? = null,
// Error message if delivery failed (when status == "failed")
val errorMessage: String? = null,
// ID of message this is a reply to (extracted from LXMF field 16 "reply_to")
val replyToMessageId: String? = null,
)

View File

@@ -1080,6 +1080,33 @@ object DatabaseModule {
}
}
// Migration from version 25 to 26: Add message reply support
// Adds replyToMessageId column and index for efficient reply lookups
// Field 16 in LXMF is used as an extensible app extensions dict: {"reply_to": "message_id"}
private val MIGRATION_25_26 =
object : Migration(25, 26) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add replyToMessageId column (nullable)
database.execSQL("ALTER TABLE messages ADD COLUMN replyToMessageId TEXT DEFAULT NULL")
// Create index for efficient reply lookups
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_messages_replyToMessageId ON messages(replyToMessageId)",
)
// Backfill existing messages from fieldsJson where field 16.reply_to exists
// SQLite json_extract uses $ for root and . for object properties
database.execSQL(
"""
UPDATE messages
SET replyToMessageId = json_extract(fieldsJson, '$."16".reply_to')
WHERE fieldsJson IS NOT NULL
AND json_extract(fieldsJson, '$."16".reply_to') IS NOT NULL
""".trimIndent(),
)
}
}
@Provides
@Singleton
fun provideColumbaDatabase(
@@ -1090,7 +1117,7 @@ object DatabaseModule {
ColumbaDatabase::class.java,
"columba_database",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26)
.build()
}

View File

@@ -39,6 +39,20 @@ data class Message(
val fieldsJson: String? = null,
val deliveryMethod: String? = null,
val errorMessage: String? = null,
val replyToMessageId: String? = null,
)
/**
* Lightweight data class for reply preview information.
* Contains only the fields needed to display a reply preview in the UI.
*/
data class ReplyPreview(
val messageId: String,
val senderName: String, // "You" or peer display name
val contentPreview: String, // Truncated to ~100 chars
val hasImage: Boolean,
val hasFileAttachment: Boolean,
val firstFileName: String?, // For file attachment preview
)
@Singleton
@@ -50,26 +64,6 @@ class ConversationRepository
private val peerIdentityDao: PeerIdentityDao,
private val localIdentityDao: LocalIdentityDao,
) {
companion object {
private const val MAX_MESSAGE_LENGTH = 10_000
private const val MAX_PEER_NAME_LENGTH = 100
/**
* Sanitizes text input for database storage.
* Removes control characters and enforces length limits.
*/
private fun sanitizeText(
text: String,
maxLength: Int,
): String {
return text
.trim()
.replace(Regex("[\\p{C}&&[^\n\r]]"), "") // Remove control chars except newlines
.replace(Regex("[ \\t]+"), " ") // Normalize spaces/tabs, preserve newlines
.take(maxLength)
}
}
/**
* Get all conversations for the active identity, sorted by most recent activity.
* Automatically switches when identity changes.
@@ -265,6 +259,7 @@ class ConversationRepository
fieldsJson = message.fieldsJson, // LXMF fields (attachments, images, etc.)
deliveryMethod = message.deliveryMethod,
errorMessage = message.errorMessage,
replyToMessageId = message.replyToMessageId, // Reply reference
)
messageDao.insertMessage(messageEntity)
}
@@ -432,6 +427,7 @@ class ConversationRepository
fieldsJson = fieldsJson,
deliveryMethod = deliveryMethod,
errorMessage = errorMessage,
replyToMessageId = replyToMessageId,
)
/**
@@ -483,4 +479,84 @@ class ConversationRepository
"Updated message ID from $oldMessageId to $newMessageId",
)
}
/**
* Get reply preview data for a message.
* Used when displaying a reply to another message.
*
* @param messageId The ID of the message being replied to
* @param peerName The display name of the peer (used when message is from them)
* @return ReplyPreview or null if message not found
*/
suspend fun getReplyPreview(
messageId: String,
peerName: String,
): ReplyPreview? {
val activeIdentity = localIdentityDao.getActiveIdentitySync() ?: return null
val previewEntity = messageDao.getReplyPreviewData(messageId, activeIdentity.identityHash)
?: return null
// Parse fieldsJson to detect attachments
val hasImage = previewEntity.fieldsJson?.contains("\"6\"") == true
val hasFileAttachment = previewEntity.fieldsJson?.contains("\"5\"") == true
// Extract first filename if file attachment exists
val firstFileName = if (hasFileAttachment && previewEntity.fieldsJson != null) {
extractFirstFileName(previewEntity.fieldsJson)
} else {
null
}
// Truncate content for preview
val contentPreview = previewEntity.content.take(REPLY_PREVIEW_MAX_LENGTH).let {
if (previewEntity.content.length > REPLY_PREVIEW_MAX_LENGTH) "$it..." else it
}
return ReplyPreview(
messageId = previewEntity.id,
senderName = if (previewEntity.isFromMe) "You" else peerName,
contentPreview = contentPreview,
hasImage = hasImage,
hasFileAttachment = hasFileAttachment,
firstFileName = firstFileName,
)
}
/**
* Extract the first filename from LXMF file attachments field.
* Field 5 format: [[filename, size, mimetype, data], ...]
*/
@Suppress("SwallowedException", "ReturnCount") // JSON parsing errors are expected
private fun extractFirstFileName(fieldsJson: String): String? {
return try {
val json = org.json.JSONObject(fieldsJson)
val field5 = json.optJSONArray("5") ?: return null
if (field5.length() == 0) return null
val firstAttachment = field5.optJSONArray(0) ?: return null
firstAttachment.optString(0).takeIf { it.isNotEmpty() }
} catch (e: Exception) {
null
}
}
companion object {
private const val MAX_MESSAGE_LENGTH = 10_000
private const val MAX_PEER_NAME_LENGTH = 100
private const val REPLY_PREVIEW_MAX_LENGTH = 100
/**
* Sanitizes text input for database storage.
* Removes control characters and enforces length limits.
*/
private fun sanitizeText(
text: String,
maxLength: Int,
): String {
return text
.trim()
.replace(Regex("[\\p{C}&&[^\n\r]]"), "") // Remove control chars except newlines
.replace(Regex("[ \\t]+"), " ") // Normalize spaces/tabs, preserve newlines
.take(maxLength)
}
}
}

View File

@@ -2669,7 +2669,7 @@ class ReticulumWrapper:
def send_lxmf_message_with_method(self, dest_hash: bytes, content: str, source_identity_private_key: bytes,
delivery_method: str = "direct", try_propagation_on_fail: bool = True,
image_data: bytes = None, image_format: str = None,
file_attachments: list = None) -> Dict:
file_attachments: list = None, reply_to_message_id: str = None) -> Dict:
"""
Send an LXMF message with explicit delivery method.
@@ -2682,6 +2682,7 @@ class ReticulumWrapper:
image_data: Optional image data bytes
image_format: Optional image format (e.g., 'jpg', 'png', 'webp')
file_attachments: Optional list of [filename, bytes] pairs for Field 5
reply_to_message_id: Optional message ID being replied to (stored in Field 16)
Returns:
Dict with 'success', 'message_hash', 'timestamp', 'delivery_method' or 'error'
@@ -2807,6 +2808,17 @@ class ReticulumWrapper:
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
f"📎 Attaching {len(converted_attachments)} file(s): {total_size} bytes total")
# Add Field 16 (app extensions) for reply_to and future features
# Field 16 is a dict that can contain: {"reply_to": "message_id", "reactions": {...}, etc.}
if reply_to_message_id:
if fields is None:
fields = {}
# Build app extensions dict
app_extensions = {"reply_to": reply_to_message_id}
fields[16] = app_extensions
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
f"📎 Replying to message: {reply_to_message_id[:16]}...")
# Create LXMF message with specified delivery method
lxmf_message = LXMF.LXMessage(
destination=recipient_lxmf_destination,
@@ -3840,6 +3852,16 @@ class ReticulumWrapper:
log_info("ReticulumWrapper", "poll_received_messages",
f"📎 Field 5: extracted {len(serialized_attachments)} file attachment(s)")
elif key == 16 and isinstance(value, dict):
# Field 16 is app extensions dict: {"reply_to": "...", "reactions": {...}, etc.}
fields_serialized['16'] = value
if 'reply_to' in value:
log_debug("ReticulumWrapper", "poll_received_messages",
f"Field 16: reply to message {value['reply_to'][:16]}...")
else:
log_debug("ReticulumWrapper", "poll_received_messages",
f"Field 16: app extensions with keys {list(value.keys())}")
elif isinstance(value, (list, tuple)) and len(value) >= 2:
# Image/audio format: [format_string, bytes_data]
if isinstance(value[1], bytes):

View File

@@ -313,6 +313,7 @@ class MockReticulumProtocol : ReticulumProtocol {
imageData: ByteArray?,
imageFormat: String?,
fileAttachments: List<Pair<String, ByteArray>>?,
replyToMessageId: String?,
): Result<MessageReceipt> {
// Mock implementation - same as sendLxmfMessage
return Result.success(

View File

@@ -217,6 +217,7 @@ interface ReticulumProtocol {
imageData: ByteArray? = null,
imageFormat: String? = null,
fileAttachments: List<Pair<String, ByteArray>>? = null,
replyToMessageId: String? = null,
): Result<MessageReceipt>
/**