From dc804b91c3f74ea8939efcf1e920ffa2e682e1a6 Mon Sep 17 00:00:00 2001 From: matthieu Texier Date: Tue, 27 Jan 2026 11:31:40 +0100 Subject: [PATCH 1/5] make http links clickable in messages --- .../messenger/ui/screens/MessagingScreen.kt | 133 ++++++++++++++++-- 1 file changed, 125 insertions(+), 8 deletions(-) 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..23d339a2 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.net.Uri +import android.os.SystemClock +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,8 +129,13 @@ 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.style.TextDecoration import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset @@ -173,6 +184,118 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +private const val URL_ANNOTATION_TAG = "url" + +private fun cleanUrlForOpening(raw: String): String { + val trimmed = raw.trim() + return trimmed.trimEnd { ch -> ch in listOf('.', ',', ';', ':', '!', '?', ')', ']', '}') } +} + +private fun toBrowsableUri(rawUrl: String): Uri { + val cleaned = cleanUrlForOpening(rawUrl) + val hasScheme = cleaned.startsWith("http://", ignoreCase = true) || cleaned.startsWith("https://", ignoreCase = true) + val withScheme = if (hasScheme) cleaned else "https://$cleaned" + return Uri.parse(withScheme) +} + +@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) { + Toast.makeText(context, "Unable to open link", Toast.LENGTH_SHORT).show() + } + } + }, + ) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun MessagingScreen( @@ -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( From 2367a156b5b6095be3fd6c2e6a0fabdeb1757fd5 Mon Sep 17 00:00:00 2001 From: matthieu Texier Date: Tue, 27 Jan 2026 19:14:10 +0100 Subject: [PATCH 2/5] add share to colomba capabilities --- app/src/main/AndroidManifest.xml | 6 ++++ .../java/com/lxmf/messenger/MainActivity.kt | 35 +++++++++++++++++++ .../messenger/ui/screens/ContactsScreen.kt | 16 +++++++-- .../messenger/ui/screens/MessagingScreen.kt | 14 +++++++- .../viewmodel/SharedTextViewModel.kt | 29 +++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 851fd57c..6c575c02 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,12 @@ + + + + + + diff --git a/app/src/main/java/com/lxmf/messenger/MainActivity.kt b/app/src/main/java/com/lxmf/messenger/MainActivity.kt index 1cfacec1..a3c769d6 100644 --- a/app/src/main/java/com/lxmf/messenger/MainActivity.kt +++ b/app/src/main/java/com/lxmf/messenger/MainActivity.kt @@ -101,7 +101,12 @@ import com.lxmf.messenger.ui.theme.ColumbaTheme import com.lxmf.messenger.util.CrashReportManager import com.lxmf.messenger.util.InterfaceReconnectSignal import com.lxmf.messenger.viewmodel.ContactsViewModel +import com.lxmf.messenger.viewmodel.MainViewModel +import com.lxmf.messenger.viewmodel.MigrationViewModel +import com.lxmf.messenger.viewmodel.NotificationSettingsViewModel import com.lxmf.messenger.viewmodel.OnboardingViewModel +import com.lxmf.messenger.viewmodel.SettingsViewModel +import com.lxmf.messenger.viewmodel.SharedTextViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -329,6 +334,15 @@ class MainActivity : ComponentActivity() { pendingNavigation.value = PendingNavigation.AddContact(lxmaUrl) } } + Intent.ACTION_SEND -> { + if (intent.type == "text/plain") { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + if (!sharedText.isNullOrBlank()) { + Log.d(TAG, "Received shared text: ${sharedText.take(120)}") + pendingNavigation.value = PendingNavigation.SharedText(sharedText) + } + } + } CallNotificationHelper.ACTION_OPEN_CALL -> { // Handle incoming call notification tap val identityHash = intent.getStringExtra(CallNotificationHelper.EXTRA_IDENTITY_HASH) @@ -463,6 +477,8 @@ sealed class PendingNavigation { data class AddContact(val lxmaUrl: String) : PendingNavigation() + data class SharedText(val text: String) : PendingNavigation() + data class IncomingCall(val identityHash: String) : PendingNavigation() data class AnswerCall(val identityHash: String) : PendingNavigation() @@ -507,6 +523,8 @@ fun ColumbaNavigation( val navController = rememberNavController() var selectedTab by remember { mutableIntStateOf(0) } + val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as ComponentActivity) + // Track if we're currently navigating to answer a call (prevents race with callState observer) var isAnsweringCall by remember { mutableStateOf(false) } @@ -583,6 +601,18 @@ fun ColumbaNavigation( pendingContactAdd = navigation.lxmaUrl Log.d("ColumbaNavigation", "Navigated to contacts for deep link: ${navigation.lxmaUrl}") } + is PendingNavigation.SharedText -> { + sharedTextViewModel.setText(navigation.text) + selectedTab = 1 + navController.navigate(Screen.Contacts.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + Log.d("ColumbaNavigation", "Handled shared text intent") + } is PendingNavigation.IncomingCall -> { // Navigate to incoming call screen val encodedHash = Uri.encode(navigation.identityHash) @@ -921,6 +951,11 @@ fun ColumbaNavigation( val encodedName = Uri.encode(peerName) navController.navigate("messaging/$encodedHash/$encodedName") }, + onStartChat = { destinationHash, peerName -> + val encodedHash = Uri.encode(destinationHash) + val encodedName = Uri.encode(peerName) + navController.navigate("messaging/$encodedHash/$encodedName") + }, ) } diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt index eb374d6c..1c64095a 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt @@ -113,6 +113,7 @@ import com.lxmf.messenger.util.validation.ValidationResult import com.lxmf.messenger.viewmodel.AddContactResult import com.lxmf.messenger.viewmodel.AnnounceStreamViewModel import com.lxmf.messenger.viewmodel.ContactsViewModel +import com.lxmf.messenger.viewmodel.SharedTextViewModel import kotlinx.coroutines.launch private const val TAG = "ContactsScreen" @@ -131,6 +132,9 @@ fun ContactsScreen( onStartChat: (destinationHash: String, peerName: String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current + val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as androidx.activity.ComponentActivity) + val sharedTextFromViewModel by sharedTextViewModel.sharedText.collectAsState() + val effectivePendingSharedText = sharedTextFromViewModel val groupedContacts by viewModel.groupedContacts.collectAsState() val contactCount by viewModel.contactCount.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() @@ -520,7 +524,11 @@ fun ContactsScreen( pendingContactToShow = contact showPendingContactSheet = true } else { - onContactClick(contact.destinationHash, contact.displayName) + if (!effectivePendingSharedText.isNullOrBlank()) { + onStartChat(contact.destinationHash, contact.displayName) + } else { + onContactClick(contact.destinationHash, contact.displayName) + } } }, onPinToggle = { viewModel.togglePin(contact.destinationHash) }, @@ -560,7 +568,11 @@ fun ContactsScreen( pendingContactToShow = contact showPendingContactSheet = true } else { - onContactClick(contact.destinationHash, contact.displayName) + if (!effectivePendingSharedText.isNullOrBlank()) { + onStartChat(contact.destinationHash, contact.displayName) + } else { + onContactClick(contact.destinationHash, contact.displayName) + } } }, onPinToggle = { viewModel.togglePin(contact.destinationHash) }, 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 23d339a2..34a76fc3 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 @@ -9,6 +9,7 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.ComponentActivity import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -177,6 +178,7 @@ import com.lxmf.messenger.util.formatRelativeTime import com.lxmf.messenger.util.validation.ValidationConstants import com.lxmf.messenger.viewmodel.ContactToggleResult import com.lxmf.messenger.viewmodel.MessagingViewModel +import com.lxmf.messenger.viewmodel.SharedTextViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -313,8 +315,18 @@ fun MessagingScreen( val listState = rememberLazyListState() var messageText by remember { mutableStateOf("") } - // Image selection state val context = androidx.compose.ui.platform.LocalContext.current + + val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as ComponentActivity) + + LaunchedEffect(destinationHash) { + val pending = sharedTextViewModel.consumeText() + if (!pending.isNullOrBlank() && messageText.isBlank()) { + messageText = pending.trim() + } + } + + // Image selection state val selectedImageData by viewModel.selectedImageData.collectAsStateWithLifecycle() val selectedImageFormat by viewModel.selectedImageFormat.collectAsStateWithLifecycle() val selectedImageIsAnimated by viewModel.selectedImageIsAnimated.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt new file mode 100644 index 00000000..754563e5 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt @@ -0,0 +1,29 @@ +package com.lxmf.messenger.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@HiltViewModel +class SharedTextViewModel + @Inject + constructor() : ViewModel() { + private val _sharedText = MutableStateFlow(null) + val sharedText: StateFlow = _sharedText + + fun setText(text: String) { + _sharedText.value = text + } + + fun consumeText(): String? { + val current = _sharedText.value + _sharedText.value = null + return current + } + + fun clear() { + _sharedText.value = null + } + } From 35f8ed7cd2250ac824531a4ac809ac3d1a36a91b Mon Sep 17 00:00:00 2001 From: matthieu Texier Date: Wed, 28 Jan 2026 06:24:06 +0100 Subject: [PATCH 3/5] Revert "add share to colomba capabilities" This reverts commit 2367a156b5b6095be3fd6c2e6a0fabdeb1757fd5. --- app/src/main/AndroidManifest.xml | 6 ---- .../java/com/lxmf/messenger/MainActivity.kt | 35 ------------------- .../messenger/ui/screens/ContactsScreen.kt | 16 ++------- .../messenger/ui/screens/MessagingScreen.kt | 14 +------- .../viewmodel/SharedTextViewModel.kt | 29 --------------- 5 files changed, 3 insertions(+), 97 deletions(-) delete mode 100644 app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c575c02..851fd57c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,12 +88,6 @@ - - - - - - diff --git a/app/src/main/java/com/lxmf/messenger/MainActivity.kt b/app/src/main/java/com/lxmf/messenger/MainActivity.kt index a3c769d6..1cfacec1 100644 --- a/app/src/main/java/com/lxmf/messenger/MainActivity.kt +++ b/app/src/main/java/com/lxmf/messenger/MainActivity.kt @@ -101,12 +101,7 @@ import com.lxmf.messenger.ui.theme.ColumbaTheme import com.lxmf.messenger.util.CrashReportManager import com.lxmf.messenger.util.InterfaceReconnectSignal import com.lxmf.messenger.viewmodel.ContactsViewModel -import com.lxmf.messenger.viewmodel.MainViewModel -import com.lxmf.messenger.viewmodel.MigrationViewModel -import com.lxmf.messenger.viewmodel.NotificationSettingsViewModel import com.lxmf.messenger.viewmodel.OnboardingViewModel -import com.lxmf.messenger.viewmodel.SettingsViewModel -import com.lxmf.messenger.viewmodel.SharedTextViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -334,15 +329,6 @@ class MainActivity : ComponentActivity() { pendingNavigation.value = PendingNavigation.AddContact(lxmaUrl) } } - Intent.ACTION_SEND -> { - if (intent.type == "text/plain") { - val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) - if (!sharedText.isNullOrBlank()) { - Log.d(TAG, "Received shared text: ${sharedText.take(120)}") - pendingNavigation.value = PendingNavigation.SharedText(sharedText) - } - } - } CallNotificationHelper.ACTION_OPEN_CALL -> { // Handle incoming call notification tap val identityHash = intent.getStringExtra(CallNotificationHelper.EXTRA_IDENTITY_HASH) @@ -477,8 +463,6 @@ sealed class PendingNavigation { data class AddContact(val lxmaUrl: String) : PendingNavigation() - data class SharedText(val text: String) : PendingNavigation() - data class IncomingCall(val identityHash: String) : PendingNavigation() data class AnswerCall(val identityHash: String) : PendingNavigation() @@ -523,8 +507,6 @@ fun ColumbaNavigation( val navController = rememberNavController() var selectedTab by remember { mutableIntStateOf(0) } - val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as ComponentActivity) - // Track if we're currently navigating to answer a call (prevents race with callState observer) var isAnsweringCall by remember { mutableStateOf(false) } @@ -601,18 +583,6 @@ fun ColumbaNavigation( pendingContactAdd = navigation.lxmaUrl Log.d("ColumbaNavigation", "Navigated to contacts for deep link: ${navigation.lxmaUrl}") } - is PendingNavigation.SharedText -> { - sharedTextViewModel.setText(navigation.text) - selectedTab = 1 - navController.navigate(Screen.Contacts.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - Log.d("ColumbaNavigation", "Handled shared text intent") - } is PendingNavigation.IncomingCall -> { // Navigate to incoming call screen val encodedHash = Uri.encode(navigation.identityHash) @@ -951,11 +921,6 @@ fun ColumbaNavigation( val encodedName = Uri.encode(peerName) navController.navigate("messaging/$encodedHash/$encodedName") }, - onStartChat = { destinationHash, peerName -> - val encodedHash = Uri.encode(destinationHash) - val encodedName = Uri.encode(peerName) - navController.navigate("messaging/$encodedHash/$encodedName") - }, ) } diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt index 1c64095a..eb374d6c 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt @@ -113,7 +113,6 @@ import com.lxmf.messenger.util.validation.ValidationResult import com.lxmf.messenger.viewmodel.AddContactResult import com.lxmf.messenger.viewmodel.AnnounceStreamViewModel import com.lxmf.messenger.viewmodel.ContactsViewModel -import com.lxmf.messenger.viewmodel.SharedTextViewModel import kotlinx.coroutines.launch private const val TAG = "ContactsScreen" @@ -132,9 +131,6 @@ fun ContactsScreen( onStartChat: (destinationHash: String, peerName: String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current - val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as androidx.activity.ComponentActivity) - val sharedTextFromViewModel by sharedTextViewModel.sharedText.collectAsState() - val effectivePendingSharedText = sharedTextFromViewModel val groupedContacts by viewModel.groupedContacts.collectAsState() val contactCount by viewModel.contactCount.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() @@ -524,11 +520,7 @@ fun ContactsScreen( pendingContactToShow = contact showPendingContactSheet = true } else { - if (!effectivePendingSharedText.isNullOrBlank()) { - onStartChat(contact.destinationHash, contact.displayName) - } else { - onContactClick(contact.destinationHash, contact.displayName) - } + onContactClick(contact.destinationHash, contact.displayName) } }, onPinToggle = { viewModel.togglePin(contact.destinationHash) }, @@ -568,11 +560,7 @@ fun ContactsScreen( pendingContactToShow = contact showPendingContactSheet = true } else { - if (!effectivePendingSharedText.isNullOrBlank()) { - onStartChat(contact.destinationHash, contact.displayName) - } else { - onContactClick(contact.destinationHash, contact.displayName) - } + onContactClick(contact.destinationHash, contact.displayName) } }, onPinToggle = { viewModel.togglePin(contact.destinationHash) }, 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 34a76fc3..23d339a2 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 @@ -9,7 +9,6 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.ComponentActivity import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -178,7 +177,6 @@ import com.lxmf.messenger.util.formatRelativeTime import com.lxmf.messenger.util.validation.ValidationConstants import com.lxmf.messenger.viewmodel.ContactToggleResult import com.lxmf.messenger.viewmodel.MessagingViewModel -import com.lxmf.messenger.viewmodel.SharedTextViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -315,18 +313,8 @@ fun MessagingScreen( val listState = rememberLazyListState() var messageText by remember { mutableStateOf("") } - val context = androidx.compose.ui.platform.LocalContext.current - - val sharedTextViewModel: SharedTextViewModel = hiltViewModel(context as ComponentActivity) - - LaunchedEffect(destinationHash) { - val pending = sharedTextViewModel.consumeText() - if (!pending.isNullOrBlank() && messageText.isBlank()) { - messageText = pending.trim() - } - } - // Image selection state + val context = androidx.compose.ui.platform.LocalContext.current val selectedImageData by viewModel.selectedImageData.collectAsStateWithLifecycle() val selectedImageFormat by viewModel.selectedImageFormat.collectAsStateWithLifecycle() val selectedImageIsAnimated by viewModel.selectedImageIsAnimated.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt deleted file mode 100644 index 754563e5..00000000 --- a/app/src/main/java/com/lxmf/messenger/viewmodel/SharedTextViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.lxmf.messenger.viewmodel - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -@HiltViewModel -class SharedTextViewModel - @Inject - constructor() : ViewModel() { - private val _sharedText = MutableStateFlow(null) - val sharedText: StateFlow = _sharedText - - fun setText(text: String) { - _sharedText.value = text - } - - fun consumeText(): String? { - val current = _sharedText.value - _sharedText.value = null - return current - } - - fun clear() { - _sharedText.value = null - } - } From 2cb0d387ad6621d904a838403255118ce71b61ea Mon Sep 17 00:00:00 2001 From: matthieu Texier Date: Thu, 29 Jan 2026 07:58:08 +0100 Subject: [PATCH 4/5] fix app:detekt findings --- .../lxmf/messenger/ui/screens/MessagingScreen.kt | 14 ++------------ .../ui/screens/MessagingScreenLinkUtils.kt | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt 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 23d339a2..3813a323 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 @@ -4,6 +4,7 @@ import android.content.Intent import android.graphics.BitmapFactory import android.net.Uri import android.os.SystemClock +import android.util.Log import android.util.Patterns import android.widget.Toast import androidx.activity.compose.BackHandler @@ -186,18 +187,6 @@ import java.util.Locale private const val URL_ANNOTATION_TAG = "url" -private fun cleanUrlForOpening(raw: String): String { - val trimmed = raw.trim() - return trimmed.trimEnd { ch -> ch in listOf('.', ',', ';', ':', '!', '?', ')', ']', '}') } -} - -private fun toBrowsableUri(rawUrl: String): Uri { - val cleaned = cleanUrlForOpening(rawUrl) - val hasScheme = cleaned.startsWith("http://", ignoreCase = true) || cleaned.startsWith("https://", ignoreCase = true) - val withScheme = if (hasScheme) cleaned else "https://$cleaned" - return Uri.parse(withScheme) -} - @Composable private fun LinkifiedMessageText( text: String, @@ -289,6 +278,7 @@ private fun LinkifiedMessageText( 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() } } 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..11ed0ac4 --- /dev/null +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt @@ -0,0 +1,15 @@ +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('.', ',', ';', ':', '!', '?', ')', ']', '}') } +} + +fun toBrowsableUri(rawUrl: String): Uri { + val cleaned = cleanUrlForOpening(rawUrl) + val hasScheme = cleaned.startsWith("http://", ignoreCase = true) || cleaned.startsWith("https://", ignoreCase = true) + val withScheme = if (hasScheme) cleaned else "https://$cleaned" + return Uri.parse(withScheme) +} From 699a3e06eff4c2fec3943115365e9ea426832f2e Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Thu, 29 Jan 2026 22:24:20 -0500 Subject: [PATCH 5/5] refactor: make link utils internal and add unit tests - Make toBrowsableUri internal visibility - Remove unused android.net.Uri import from MessagingScreen.kt - Add toBrowsableUrl helper for testability (pure string function) - Add 32 unit tests covering scheme handling, punctuation stripping, whitespace trimming, paths, query strings, ports, and edge cases Co-Authored-By: Claude Opus 4.5 --- .../messenger/ui/screens/MessagingScreen.kt | 96 ++++--- .../ui/screens/MessagingScreenLinkUtils.kt | 19 +- .../screens/MessagingScreenLinkUtilsTest.kt | 234 ++++++++++++++++++ 3 files changed, 305 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtilsTest.kt 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 3813a323..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,7 +2,6 @@ package com.lxmf.messenger.ui.screens import android.content.Intent import android.graphics.BitmapFactory -import android.net.Uri import android.os.SystemClock import android.util.Log import android.util.Patterns @@ -136,8 +135,8 @@ 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.style.TextDecoration 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 @@ -441,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) @@ -897,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 } @@ -1056,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) }, @@ -1379,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 = { @@ -1408,7 +1415,8 @@ fun MessageBubble( // Large GIF without bubble background AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(message.imageData) .crossfade(true) .build(), @@ -1430,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), @@ -1489,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 = { @@ -1545,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(), @@ -1871,7 +1881,8 @@ fun MessageInputBar( // Animated GIF - use Coil for preview AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(selectedImageData) .crossfade(true) .build(), @@ -1887,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 -> @@ -1963,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( @@ -2167,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 = @@ -2239,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 = @@ -2263,7 +2278,8 @@ private fun FullscreenAnimatedImageDialog( ) { AsyncImage( model = - ImageRequest.Builder(context) + ImageRequest + .Builder(context) .data(imageData) .crossfade(true) .build(), @@ -2431,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. @@ -2451,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 index 11ed0ac4..79635cb6 100644 --- a/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt +++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreenLinkUtils.kt @@ -7,9 +7,22 @@ private fun cleanUrlForOpening(raw: String): String { return trimmed.trimEnd { ch -> ch in listOf('.', ',', ';', ':', '!', '?', ')', ']', '}') } } -fun toBrowsableUri(rawUrl: String): Uri { +/** + * 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) - val withScheme = if (hasScheme) cleaned else "https://$cleaned" - return Uri.parse(withScheme) + 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) + } +}