diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt index 7f17b0f6..b141f98a 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt @@ -2,6 +2,9 @@ package com.lxmf.messenger.ui.screens import android.content.Intent import android.graphics.BitmapFactory +import android.os.SystemClock +import android.util.Log +import android.util.Patterns import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -23,7 +26,10 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.content.MediaType import androidx.compose.foundation.content.contentReceiver import androidx.compose.foundation.content.hasMediaType +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement @@ -123,9 +129,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -173,6 +184,107 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +private const val URL_ANNOTATION_TAG = "url" + +@Composable +private fun LinkifiedMessageText( + text: String, + isFromMe: Boolean, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val viewConfiguration = LocalViewConfiguration.current + + val textColor = + if (isFromMe) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + val linkColor = MaterialTheme.colorScheme.primary + + val annotatedText = + remember(text, linkColor) { + val matches = Patterns.WEB_URL.matcher(text) + buildAnnotatedString { + var currentIndex = 0 + while (matches.find()) { + val start = matches.start() + val end = matches.end() + if (start > currentIndex) { + append(text.substring(currentIndex, start)) + } + + val urlText = text.substring(start, end) + val linkStart = length + append(urlText) + val linkEnd = length + addStyle( + style = + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + start = linkStart, + end = linkEnd, + ) + addStringAnnotation( + tag = URL_ANNOTATION_TAG, + annotation = urlText, + start = linkStart, + end = linkEnd, + ) + currentIndex = end + } + + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } + } + } + + var layoutResult: TextLayoutResult? by remember { mutableStateOf(null) } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyLarge, + color = textColor, + onTextLayout = { layoutResult = it }, + modifier = + modifier.pointerInput(annotatedText, viewConfiguration.longPressTimeoutMillis) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val downTime = SystemClock.uptimeMillis() + val up = waitForUpOrCancellation() ?: return@awaitEachGesture + val upTime = SystemClock.uptimeMillis() + + if (upTime - downTime >= viewConfiguration.longPressTimeoutMillis) { + return@awaitEachGesture + } + + val result = layoutResult ?: return@awaitEachGesture + val offset = result.getOffsetForPosition(up.position) + val url = + annotatedText + .getStringAnnotations(URL_ANNOTATION_TAG, offset, offset) + .firstOrNull() + ?.item + ?: return@awaitEachGesture + + down.consume() + up.consume() + + try { + context.startActivity(Intent(Intent.ACTION_VIEW, toBrowsableUri(url))) + } catch (e: Exception) { + Log.w("MessagingScreen", "Unable to open link: $url", e) + Toast.makeText(context, "Unable to open link", Toast.LENGTH_SHORT).show() + } + } + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun MessagingScreen( @@ -328,18 +440,20 @@ fun MessagingScreen( is FileUtils.FileReadResult.FileTooLarge -> { val maxSizeKb = result.maxSize / 1024 val actualSizeKb = result.actualSize / 1024 - Toast.makeText( - context, - "File too large (${actualSizeKb}KB). Max size is ${maxSizeKb}KB.", - Toast.LENGTH_LONG, - ).show() + Toast + .makeText( + context, + "File too large (${actualSizeKb}KB). Max size is ${maxSizeKb}KB.", + Toast.LENGTH_LONG, + ).show() } is FileUtils.FileReadResult.Error -> { - Toast.makeText( - context, - "Failed to attach file: ${result.message}", - Toast.LENGTH_SHORT, - ).show() + Toast + .makeText( + context, + "Failed to attach file: ${result.message}", + Toast.LENGTH_SHORT, + ).show() } } viewModel.setProcessingFile(false) @@ -784,7 +898,8 @@ fun MessagingScreen( val cachedImage = decodedResult?.bitmap ?: if (message.decodedImage == null && loadedImageIds.contains(message.id)) { - com.lxmf.messenger.ui.model.ImageCache.get(message.id) + com.lxmf.messenger.ui.model.ImageCache + .get(message.id) } else { message.decodedImage } @@ -943,7 +1058,10 @@ fun MessagingScreen( pagingItems.itemSnapshotList .find { it?.id == state.messageId } message?.let { - clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(it.content)) + clipboardManager.setText( + androidx.compose.ui.text + .AnnotatedString(it.content), + ) } }, onViewDetails = { onViewMessageDetails(state.messageId) }, @@ -1266,19 +1384,21 @@ fun MessageBubble( val width = this.size.width.toInt() val height = this.size.height.toInt() onDrawWithContent { - graphicsLayer.record(size = androidx.compose.ui.unit.IntSize(width, height)) { + graphicsLayer.record( + size = + androidx.compose.ui.unit + .IntSize(width, height), + ) { this@onDrawWithContent.drawContent() } drawContent() } - } - .onGloballyPositioned { coordinates -> + }.onGloballyPositioned { coordinates -> bubbleX = coordinates.positionInRoot().x bubbleY = coordinates.positionInRoot().y bubbleWidth = coordinates.size.width bubbleHeight = coordinates.size.height - } - .scale(scale) + }.scale(scale) .combinedClickable( onClick = { showFullscreenImage = true }, onLongClick = { @@ -1295,7 +1415,8 @@ fun MessageBubble( // Large GIF without bubble background AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(message.imageData) .crossfade(true) .build(), @@ -1317,8 +1438,7 @@ fun MessageBubble( .background( color = Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 6.dp, vertical = 2.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -1376,19 +1496,21 @@ fun MessageBubble( val width = this.size.width.toInt() val height = this.size.height.toInt() onDrawWithContent { - graphicsLayer.record(size = androidx.compose.ui.unit.IntSize(width, height)) { + graphicsLayer.record( + size = + androidx.compose.ui.unit + .IntSize(width, height), + ) { this@onDrawWithContent.drawContent() } drawContent() } - } - .onGloballyPositioned { coordinates -> + }.onGloballyPositioned { coordinates -> bubbleX = coordinates.positionInRoot().x bubbleY = coordinates.positionInRoot().y bubbleWidth = coordinates.size.width bubbleHeight = coordinates.size.height - } - .scale(scale) // Apply scale animation after bitmap capture + }.scale(scale) // Apply scale animation after bitmap capture .combinedClickable( onClick = {}, onLongClick = { @@ -1432,7 +1554,8 @@ fun MessageBubble( // Animated GIF - use Coil for animated rendering AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(imageData) .crossfade(true) .build(), @@ -1571,15 +1694,9 @@ fun MessageBubble( } } - Text( + LinkifiedMessageText( text = message.content, - style = MaterialTheme.typography.bodyLarge, - color = - if (isFromMe) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, + isFromMe = isFromMe, ) Spacer(modifier = Modifier.height(4.dp)) Row( @@ -1764,7 +1881,8 @@ fun MessageInputBar( // Animated GIF - use Coil for preview AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(selectedImageData) .crossfade(true) .build(), @@ -1780,7 +1898,8 @@ fun MessageInputBar( // Static image - use decoded bitmap val bitmap = remember(selectedImageData) { - BitmapFactory.decodeByteArray(selectedImageData, 0, selectedImageData.size) + BitmapFactory + .decodeByteArray(selectedImageData, 0, selectedImageData.size) ?.asImageBitmap() } bitmap?.let { imageBitmap -> @@ -1856,8 +1975,7 @@ fun MessageInputBar( .background( MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(24.dp), - ) - .border(1.dp, borderColor, RoundedCornerShape(24.dp)) + ).border(1.dp, borderColor, RoundedCornerShape(24.dp)) .padding(horizontal = 16.dp, vertical = 12.dp), ) { BasicTextField( @@ -2060,7 +2178,9 @@ private fun FullscreenImageDialog( androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false), + properties = + androidx.compose.ui.window + .DialogProperties(usePlatformDefaultWidth = false), ) { Box( modifier = @@ -2132,7 +2252,9 @@ private fun FullscreenAnimatedImageDialog( androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false), + properties = + androidx.compose.ui.window + .DialogProperties(usePlatformDefaultWidth = false), ) { Box( modifier = @@ -2156,7 +2278,8 @@ private fun FullscreenAnimatedImageDialog( ) { AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(imageData) .crossfade(true) .build(), @@ -2324,14 +2447,13 @@ private fun getSyncStatusText(syncProgress: SyncProgress): String = /** * Format file size in human-readable format. */ -private fun formatFileSize(bytes: Long): String { - return when { +private fun formatFileSize(bytes: Long): String = + when { bytes < 1024 -> "$bytes B" bytes < 1024 * 1024 -> "${bytes / 1024} KB" bytes < 1024 * 1024 * 1024 -> String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) else -> String.format(java.util.Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) } -} /** * Get the status icon character for a message status. @@ -2344,12 +2466,11 @@ private fun formatFileSize(bytes: Long): String { * - "!" (exclamation) for failed - delivery failed * - "" (empty) for unknown status */ -internal fun getMessageStatusIcon(status: String): String { - return when (status) { +internal fun getMessageStatusIcon(status: String): String = + when (status) { "pending" -> "○" "sent", "retrying_propagated", "propagated" -> "✓" "delivered" -> "✓✓" "failed" -> "!" else -> "" } -} diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt new file mode 100644 index 00000000..79635cb6 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt @@ -0,0 +1,28 @@ +package com.lxmf.messenger.ui.screens + +import android.net.Uri + +private fun cleanUrlForOpening(raw: String): String { + val trimmed = raw.trim() + return trimmed.trimEnd { ch -> ch in listOf('.', ',', ';', ':', '!', '?', ')', ']', '}') } +} + +/** + * Cleans a raw URL string and ensures it has a proper scheme. + * - Trims whitespace + * - Removes trailing punctuation (period, comma, etc.) + * - Adds https:// scheme if none present + * + * @return The cleaned URL string ready for parsing + */ +internal fun toBrowsableUrl(rawUrl: String): String { + val cleaned = cleanUrlForOpening(rawUrl) + val hasScheme = cleaned.startsWith("http://", ignoreCase = true) || cleaned.startsWith("https://", ignoreCase = true) + return if (hasScheme) cleaned else "https://$cleaned" +} + +/** + * Converts a raw URL string to a browsable Uri. + * @see toBrowsableUrl for the string cleaning logic + */ +internal fun toBrowsableUri(rawUrl: String): Uri = Uri.parse(toBrowsableUrl(rawUrl)) diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtilsTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtilsTest.kt new file mode 100644 index 00000000..6c6e8b8f --- /dev/null +++ b/app/src/test/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtilsTest.kt @@ -0,0 +1,234 @@ +package com.lxmf.messenger.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for MessagingScreenLinkUtils. + * + * Tests the URL parsing and cleaning logic used for clickable links in messages. + * Tests use toBrowsableUrl (returns String) instead of toBrowsableUri (returns Android Uri) + * to avoid Android framework dependencies in unit tests. + */ +class MessagingScreenLinkUtilsTest { + // ========================================== + // Scheme Handling Tests + // ========================================== + + @Test + fun `adds https scheme to bare domain`() { + val result = toBrowsableUrl("example.com") + assertEquals("https://example.com", result) + } + + @Test + fun `preserves existing http scheme`() { + val result = toBrowsableUrl("http://example.com") + assertEquals("http://example.com", result) + } + + @Test + fun `preserves existing https scheme`() { + val result = toBrowsableUrl("https://example.com") + assertEquals("https://example.com", result) + } + + @Test + fun `handles uppercase HTTP scheme`() { + val result = toBrowsableUrl("HTTP://example.com") + assertEquals("HTTP://example.com", result) + } + + @Test + fun `handles uppercase HTTPS scheme`() { + val result = toBrowsableUrl("HTTPS://example.com") + assertEquals("HTTPS://example.com", result) + } + + @Test + fun `handles mixed case scheme`() { + val result = toBrowsableUrl("HtTpS://example.com") + assertEquals("HtTpS://example.com", result) + } + + // ========================================== + // Trailing Punctuation Stripping Tests + // ========================================== + + @Test + fun `strips trailing period`() { + val result = toBrowsableUrl("example.com.") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing comma`() { + val result = toBrowsableUrl("example.com,") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing exclamation mark`() { + val result = toBrowsableUrl("example.com!") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing question mark`() { + val result = toBrowsableUrl("example.com?") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing semicolon`() { + val result = toBrowsableUrl("example.com;") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing colon`() { + val result = toBrowsableUrl("example.com:") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing closing parenthesis`() { + val result = toBrowsableUrl("example.com)") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing closing bracket`() { + val result = toBrowsableUrl("example.com]") + assertEquals("https://example.com", result) + } + + @Test + fun `strips trailing closing brace`() { + val result = toBrowsableUrl("example.com}") + assertEquals("https://example.com", result) + } + + @Test + fun `strips multiple trailing punctuation marks`() { + val result = toBrowsableUrl("example.com!!") + assertEquals("https://example.com", result) + } + + @Test + fun `strips mixed trailing punctuation`() { + val result = toBrowsableUrl("example.com).") + assertEquals("https://example.com", result) + } + + // ========================================== + // Whitespace Handling Tests + // ========================================== + + @Test + fun `trims leading whitespace`() { + val result = toBrowsableUrl(" example.com") + assertEquals("https://example.com", result) + } + + @Test + fun `trims trailing whitespace`() { + val result = toBrowsableUrl("example.com ") + assertEquals("https://example.com", result) + } + + @Test + fun `trims both leading and trailing whitespace`() { + val result = toBrowsableUrl(" example.com ") + assertEquals("https://example.com", result) + } + + // ========================================== + // Path and Query String Tests + // ========================================== + + @Test + fun `preserves path`() { + val result = toBrowsableUrl("example.com/path/to/page") + assertEquals("https://example.com/path/to/page", result) + } + + @Test + fun `preserves query string`() { + val result = toBrowsableUrl("example.com/search?q=test") + assertEquals("https://example.com/search?q=test", result) + } + + @Test + fun `preserves fragment`() { + val result = toBrowsableUrl("example.com/page#section") + assertEquals("https://example.com/page#section", result) + } + + @Test + fun `preserves complex URL with path query and fragment`() { + val result = toBrowsableUrl("https://example.com/path?key=value&other=123#section") + assertEquals("https://example.com/path?key=value&other=123#section", result) + } + + @Test + fun `strips trailing punctuation but preserves query string`() { + // Note: trailing exclamation is stripped from end of URL + val result = toBrowsableUrl("example.com/search?q=test!") + assertEquals("https://example.com/search?q=test", result) + } + + // ========================================== + // Port Number Tests + // ========================================== + + @Test + fun `preserves port number`() { + val result = toBrowsableUrl("example.com:8080") + assertEquals("https://example.com:8080", result) + } + + @Test + fun `preserves port number with path`() { + val result = toBrowsableUrl("localhost:3000/api/users") + assertEquals("https://localhost:3000/api/users", result) + } + + // ========================================== + // IP Address Tests + // ========================================== + + @Test + fun `handles IPv4 address`() { + val result = toBrowsableUrl("192.168.1.1") + assertEquals("https://192.168.1.1", result) + } + + @Test + fun `handles IPv4 address with port`() { + val result = toBrowsableUrl("192.168.1.1:8080") + assertEquals("https://192.168.1.1:8080", result) + } + + // ========================================== + // Edge Cases + // ========================================== + + @Test + fun `handles URL with www prefix`() { + val result = toBrowsableUrl("www.example.com") + assertEquals("https://www.example.com", result) + } + + @Test + fun `handles subdomain`() { + val result = toBrowsableUrl("blog.example.com") + assertEquals("https://blog.example.com", result) + } + + @Test + fun `handles URL with authentication`() { + val result = toBrowsableUrl("https://user:pass@example.com") + assertEquals("https://user:pass@example.com", result) + } +}