mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
Merge pull request #129 from torlando-tech/feature/message-replies
feat: add Signal-style reply-to-message feature
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -217,6 +217,7 @@ interface ReticulumProtocol {
|
||||
imageData: ByteArray? = null,
|
||||
imageFormat: String? = null,
|
||||
fileAttachments: List<Pair<String, ByteArray>>? = null,
|
||||
replyToMessageId: String? = null,
|
||||
): Result<MessageReceipt>
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user