diff --git a/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt index 6feca2a7..e53b2cf5 100644 --- a/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt +++ b/app/src/main/java/com/bitchat/android/service/ConversationNotificationReceiver.kt @@ -8,8 +8,10 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ConversationListPreferences import com.bitchat.android.services.MessageRouter import com.bitchat.android.ui.NotificationManager +import com.bitchat.android.util.TrackingUrlDetector import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -40,6 +42,20 @@ class ConversationNotificationReceiver : BroadcastReceiver() { ?.trim() ?.takeIf(String::isNotEmpty) ?: return@launch + if (TrackingUrlDetector.containsTrackingUrl(reply)) { + ConversationListPreferences.getInstance(context).appendDraft( + conversationID, + reply, + ) + NotificationManager.showTrackingReplyWarning( + context = context.applicationContext, + conversationID = conversationID, + senderNickname = intent.getStringExtra( + NotificationManager.EXTRA_SENDER_NICKNAME, + ).orEmpty(), + ) + return@launch + } // A notification can outlive the process/service that posted it. Promote // the mesh runtime before dispatch so Android keeps the transport alive // after this short-lived receiver finishes. diff --git a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt index 658fa9fc..9d9bcd8c 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationListPreferences.kt @@ -73,9 +73,11 @@ internal class ConversationListPreferences private constructor( fun isPinned(conversationID: String): Boolean = normalize(conversationID) in _pinned.value + @Synchronized fun draftFor(conversationID: String): String? = _drafts.value[normalize(conversationID)] + @Synchronized fun setDraft(conversationID: String, text: String) { val key = normalize(conversationID) val updated = _drafts.value.toMutableMap() @@ -88,6 +90,21 @@ internal class ConversationListPreferences private constructor( saveDrafts(retained) } + @Synchronized + fun appendDraft(conversationID: String, text: String) { + val key = normalize(conversationID) + val updated = _drafts.value.toMutableMap() + updated.remove(key) + updated[key] = mergeConversationDrafts( + existingDraft = _drafts.value[key], + appendedText = text, + maxChars = MAX_DRAFT_CHARS, + ) + val retained = boundDrafts(updated) + _drafts.value = retained + saveDrafts(retained) + } + fun removeConversation(conversationID: String) { val key = normalize(conversationID) _pinned.value = _pinned.value - key @@ -192,3 +209,18 @@ internal class ConversationListPreferences private constructor( } } + +internal fun mergeConversationDrafts( + existingDraft: String?, + appendedText: String, + maxChars: Int, +): String { + val boundedAppend = appendedText.takeLast(maxChars) + val existing = existingDraft?.takeIf(String::isNotBlank) ?: return boundedAppend + val existingLimit = (maxChars - boundedAppend.length - 1).coerceAtLeast(0) + return if (existingLimit == 0) { + boundedAppend + } else { + existing.take(existingLimit) + "\n" + boundedAppend + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..283aa575 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -47,6 +48,7 @@ import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.ui.media.FullScreenImageViewer import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.util.TrackingUrlDetector /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -395,9 +397,9 @@ fun ChatScreen(viewModel: ChatViewModel) { viewModel.updateCommandSuggestions(newText.text) viewModel.updateMentionSuggestions(newText.text) }, - onSend = { - if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) { accepted -> + onSend = { submittedText -> + if (submittedText.trim().isNotEmpty()) { + viewModel.sendMessage(submittedText.trim()) { accepted -> if (accepted) { messageText = TextFieldValue("") viewModel.setConversationDraft(selectedPrivatePeer, "") @@ -628,7 +630,7 @@ private fun NearbyNotesStrip( fun ChatInputSection( messageText: TextFieldValue, onMessageTextChange: (TextFieldValue) -> Unit, - onSend: () -> Unit, + onSend: (String) -> Unit, onSendVoiceNote: (String?, String?, String) -> Unit, onSendImageNote: (String?, String?, String) -> Unit, onSendFileNote: (String?, String?, String) -> Unit, @@ -648,9 +650,17 @@ fun ChatInputSection( modifier: Modifier = Modifier ) { val context = androidx.compose.ui.platform.LocalContext.current + var pendingTrackedText by rememberSaveable { mutableStateOf(null) } val activePublicTalker by remember(context) { com.bitchat.android.features.voice.LiveVoiceManager.getInstance(context).activePublicTalker }.collectAsState() + val requestSend = { + if (TrackingUrlDetector.containsTrackingUrl(messageText.text)) { + pendingTrackedText = messageText.text + } else { + onSend(messageText.text) + } + } Column( // Flat, slightly translucent screen background — the same treatment as the top bar, so the // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked @@ -718,7 +728,7 @@ fun ChatInputSection( MessageInput( value = messageText, onValueChange = onMessageTextChange, - onSend = onSend, + onSend = requestSend, onSendVoiceNote = onSendVoiceNote, onSendImageNote = onSendImageNote, onSendFileNote = onSendFileNote, @@ -732,6 +742,17 @@ fun ChatInputSection( modifier = Modifier.fillMaxWidth() ) } + + pendingTrackedText?.let { submittedText -> + TrackingWarningDialog( + message = R.string.tracking_link_send_warning, + onConfirm = { + pendingTrackedText = null + onSend(submittedText) + }, + onDismiss = { pendingTrackedText = null }, + ) + } } /** diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index bf9e2f7c..772fee8a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -857,6 +857,8 @@ class ChatViewModel( ?.let(conversationListPreferences.drafts.value::get) .orEmpty() + internal val conversationDrafts = conversationListPreferences.drafts + internal fun setConversationDraft(conversationID: String?, text: String) { if (conversationID.isNullOrBlank()) return conversationListPreferences.setDraft(conversationID, text) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index f3c49480..64cbce3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,6 +33,7 @@ import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.util.TrackingUrlDetector import java.text.SimpleDateFormat import java.util.* import java.util.Calendar @@ -76,6 +78,7 @@ fun LocationNotesSheet( // Input field state var draft by remember { mutableStateOf("") } + var pendingTrackedText by rememberSaveable { mutableStateOf(null) } val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS // Scroll state @@ -216,8 +219,12 @@ fun LocationNotesSheet( onSend = { val content = draft.trim() if (content.isNotEmpty()) { - notesManager.send(content, nickname) - draft = "" + if (TrackingUrlDetector.containsTrackingUrl(content)) { + pendingTrackedText = content + } else { + notesManager.send(content, nickname) + draft = "" + } } } ) @@ -225,6 +232,20 @@ fun LocationNotesSheet( } } } + + pendingTrackedText?.let { content -> + TrackingWarningDialog( + message = R.string.tracking_link_send_warning, + onConfirm = { + pendingTrackedText = null + if (content.isNotEmpty()) { + notesManager.send(content, nickname) + draft = "" + } + }, + onDismiss = { pendingTrackedText = null }, + ) + } } /** diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c..8db4920f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1836,6 +1836,8 @@ fun PrivateChatSheet( // Input section. No divider here: ChatInputSection draws its own fade and // hairline. + val conversationDrafts by viewModel.conversationDrafts + .collectAsStateWithLifecycle() var messageText by remember(peerID) { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( @@ -1843,6 +1845,17 @@ fun PrivateChatSheet( ) ) } + val persistedDraft = conversationDrafts[ + ContactDirectory.canonicalConversationId(peerID).lowercase() + ].orEmpty() + LaunchedEffect(persistedDraft) { + if (persistedDraft != messageText.text) { + messageText = androidx.compose.ui.text.input.TextFieldValue( + text = persistedDraft, + selection = androidx.compose.ui.text.TextRange(persistedDraft.length), + ) + } + } ChatInputSection( messageText = messageText, @@ -1853,10 +1866,10 @@ fun PrivateChatSheet( // renders its own popups as hidden, so an update only leaves // a stale popup behind for the main composer. }, - onSend = { - if (messageText.text.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.text.trim()) { accepted -> - if (accepted) { + onSend = { submittedText -> + if (submittedText.trim().isNotEmpty()) { + viewModel.sendMessage(submittedText.trim()) { accepted -> + if (accepted && messageText.text == submittedText) { messageText = androidx.compose.ui.text.input.TextFieldValue("") viewModel.setConversationDraft(peerID, "") diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 8eb76c44..475ff7b8 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -88,6 +88,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem +import com.bitchat.android.util.TrackingUrlDetector import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.ChatUiModeManager @@ -761,6 +762,15 @@ internal fun TextMessageLayout( val isSelf = message.isFromSelf(currentUserNickname, myPeerId) val haptic = LocalHapticFeedback.current val context = LocalContext.current + var pendingTrackedUrl by rememberSaveable { mutableStateOf(null) } + val openOrWarn: (String) -> Unit = { rawUrl -> + if (TrackingUrlDetector.hasTrackingParameter(normalizeMessageUrl(rawUrl))) { + pendingTrackedUrl = rawUrl + } else { + openMessageUrl(context, rawUrl) + } + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } val handleLongPress: () -> Unit = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onMessageLongPress?.invoke(message) @@ -802,12 +812,10 @@ internal fun TextMessageLayout( timeFormatter = timeFormatter, onNicknameClick = onNicknameClick, onLongPress = handleLongPress, + onUrlClick = openOrWarn, modifier = modifier, ) - return - } - - Column( + } else Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), ) { @@ -847,8 +855,7 @@ internal fun TextMessageLayout( } "url_click" -> { - openMessageUrl(context, item) - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + openOrWarn(item) true } @@ -862,6 +869,17 @@ internal fun TextMessageLayout( style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } + + pendingTrackedUrl?.let { url -> + TrackingWarningDialog( + message = R.string.tracking_link_open_warning, + onConfirm = { + pendingTrackedUrl = null + openMessageUrl(context, url) + }, + onDismiss = { pendingTrackedUrl = null }, + ) + } } /** @@ -884,6 +902,7 @@ private fun BubbleTextMessageLayout( timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, onLongPress: () -> Unit, + onUrlClick: (String) -> Unit, modifier: Modifier = Modifier, ) { val palette = LocalBitchatPalette.current @@ -1002,8 +1021,7 @@ private fun BubbleTextMessageLayout( } "url_click" -> { - openMessageUrl(context, item) - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onUrlClick(item) true } diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 2b77075d..d5d388a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -80,6 +80,69 @@ class NotificationManager( NotificationManagerCompat.from(context).cancel(canonicalID.hashCode()) } } + + fun showTrackingReplyWarning( + context: Context, + conversationID: String, + senderNickname: String, + ) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, conversationID) + putExtra(EXTRA_SENDER_NICKNAME, senderNickname) + } + val contentIntent = PendingIntent.getActivity( + context, + NOTIFICATION_REQUEST_CODE + conversationID.hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notificationID = conversationID.hashCode() + val previousGroup = runCatching { + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE, + ) as AndroidNotificationManager + manager.activeNotifications + .firstOrNull { it.id == notificationID } + ?.notification + ?.group + }.getOrNull() + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.tracking_link_warning_title)) + .setContentText(context.getString(R.string.tracking_link_reply_saved)) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion( + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.notification_private_message)) + .setContentText(context.getString(R.string.notification_content_hidden)) + .build(), + ) + .apply { + val shouldGroup = synchronized(liveManagers) { + liveManagers.any { it.pendingNotifications.size > 1 } + } + if (previousGroup != null || shouldGroup) { + setGroup(previousGroup ?: GROUP_KEY_DM) + } + } + .build() + + if ( + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context).notify(notificationID, notification) + } + } } private val systemNotificationManager = diff --git a/app/src/main/java/com/bitchat/android/ui/TrackingWarningDialog.kt b/app/src/main/java/com/bitchat/android/ui/TrackingWarningDialog.kt new file mode 100644 index 00000000..b6befc11 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/TrackingWarningDialog.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.ui + +import androidx.annotation.StringRes +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +@Composable +internal fun TrackingWarningDialog( + @StringRes message: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.tracking_link_warning_title)) }, + text = { Text(stringResource(message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.no)) + } + }, + ) +} diff --git a/app/src/main/java/com/bitchat/android/util/TrackingUrlDetector.kt b/app/src/main/java/com/bitchat/android/util/TrackingUrlDetector.kt new file mode 100644 index 00000000..fbd18b63 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/TrackingUrlDetector.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.util + +import java.net.URLDecoder +import java.util.Locale + +object TrackingUrlDetector { + private val exactTrackingParameters = setOf( + "dclid", + "fbclid", + "gclid", + "igsh", + "msclkid", + "ttclid", + "twclid", + ) + + private val urlPattern = Regex( + pattern = """(?"']+|(?"']*)?""", + option = RegexOption.IGNORE_CASE, + ) + + fun containsTrackingUrl(text: String): Boolean = + urlPattern.findAll(text).any { match -> + hasTrackingParameter(match.value.trimUrlPunctuation()) + } + + fun hasTrackingParameter(url: String): Boolean { + val queryStart = url.indexOf('?') + if (queryStart < 0) return false + val firstFragment = url.indexOf('#') + if (firstFragment in 0 until queryStart) return false + + val fragmentStart = url.indexOf('#', startIndex = queryStart + 1) + .let { if (it >= 0) it else url.length } + val query = url.substring(queryStart + 1, fragmentStart) + + return query.split(querySeparator).any { parameter -> + val encodedName = parameter.substringBefore('=') + val name = runCatching { + URLDecoder.decode(encodedName, Charsets.UTF_8.name()) + }.getOrNull()?.lowercase(Locale.ROOT) ?: return@any false + + name.startsWith("utm_") || name in exactTrackingParameters + } + } + + private fun String.trimUrlPunctuation(): String = + trimEnd('.', ',', ';', ':', '!', '"', '\'', ')', ']', '}') + + private val querySeparator = Regex("&(?:amp;)?", RegexOption.IGNORE_CASE) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a73b5bbb..74758176 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -136,6 +136,14 @@ %1$s cannot be sent encrypted to %2$s because their client is older. %3$s Send this file once + + Possible tracker detected + Your link probably contains a tracker. Proceed? + This link probably contains a tracker. Open the link? + Possible tracker detected. Reply saved as a draft for review. + Yes + No + 📎 File Received 📄 %1$s diff --git a/app/src/test/kotlin/com/bitchat/android/service/ConversationNotificationReceiverTest.kt b/app/src/test/kotlin/com/bitchat/android/service/ConversationNotificationReceiverTest.kt new file mode 100644 index 00000000..3b5cadfd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/ConversationNotificationReceiverTest.kt @@ -0,0 +1,44 @@ +package com.bitchat.android.service + +import com.bitchat.android.services.mergeConversationDrafts +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationNotificationReceiverTest { + @Test + fun `tracked notification reply preserves an existing draft`() { + assertEquals( + "unfinished message\nhttps://instagram.com/p/example?igsh=abc", + mergeConversationDrafts( + existingDraft = "unfinished message", + appendedText = "https://instagram.com/p/example?igsh=abc", + maxChars = 8_000, + ), + ) + } + + @Test + fun `tracked notification reply becomes draft when none exists`() { + assertEquals( + "https://instagram.com/p/example?igsh=abc", + mergeConversationDrafts( + existingDraft = null, + appendedText = "https://instagram.com/p/example?igsh=abc", + maxChars = 8_000, + ), + ) + } + + @Test + fun `tracked reply is retained when existing draft reaches size limit`() { + val reply = "https://instagram.com/p/example?igsh=abc" + val merged = mergeConversationDrafts( + existingDraft = "a".repeat(8_000), + appendedText = reply, + maxChars = 8_000, + ) + + assertEquals(8_000, merged.length) + assertEquals(true, merged.endsWith(reply)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/TrackingUrlDetectorTest.kt b/app/src/test/kotlin/com/bitchat/android/util/TrackingUrlDetectorTest.kt new file mode 100644 index 00000000..47c14678 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/TrackingUrlDetectorTest.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrackingUrlDetectorTest { + @Test + fun `detects supported tracking query parameters`() { + listOf("igsh", "fbclid", "gclid", "dclid", "msclkid", "twclid", "ttclid").forEach { key -> + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://example.com/path?$key=value")) + } + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://example.com/?utm_source=chat")) + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://example.com/?UTM_CUSTOM=value")) + } + + @Test + fun `detects empty encoded and mixed case parameter names`() { + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://instagram.com/p/example?IGSH")) + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://instagram.com/p/example?foo=1&%69gsh=&bar=2")) + assertTrue(TrackingUrlDetector.hasTrackingParameter("https://instagram.com/p/example?foo=1&igsh=value")) + } + + @Test + fun `ignores tracking text outside exact query parameter names`() { + listOf( + "https://example.com/igsh/value", + "https://example.com/#igsh=value", + "https://example.com/?other=igsh", + "https://example.com/?igshid=value", + "https://example.com/?notutm_source=value", + "https://example.com/?redirect=https://other.test/?igsh=value", + "ordinary igsh text", + ).forEach { url -> + assertFalse(TrackingUrlDetector.hasTrackingParameter(url)) + } + } + + @Test + fun `finds tracked urls in outgoing text`() { + assertTrue( + TrackingUrlDetector.containsTrackingUrl( + "See https://www.instagram.com/reel/example/?foo=1&igsh=abc and let me know.", + ), + ) + assertTrue(TrackingUrlDetector.containsTrackingUrl("instagram.com/p/example?igsh=abc")) + assertTrue(TrackingUrlDetector.containsTrackingUrl("/msg alice www.example.com?utm_medium=chat")) + assertTrue(TrackingUrlDetector.containsTrackingUrl("example.com:8080/path?utm_source=chat")) + assertTrue(TrackingUrlDetector.containsTrackingUrl("HTTPS://EXAMPLE.COM/?FBCLID=abc")) + } + + @Test + fun `ignores ordinary text emails and safe urls`() { + listOf( + "igsh is a parameter name", + "person@instagram.com?igsh=abc", + "https://instagram.com/p/example", + "https://instagram.com/path/igsh?other=value", + "example.com?campaign=igsh", + ).forEach { text -> + assertFalse(TrackingUrlDetector.containsTrackingUrl(text)) + } + } + + @Test + fun `handles sentence punctuation and fragments`() { + assertTrue(TrackingUrlDetector.containsTrackingUrl("Open (https://example.com/?igsh=abc).")) + assertFalse(TrackingUrlDetector.containsTrackingUrl("Open https://example.com/#page?igsh=abc.")) + } + + @Test + fun `malformed percent encoding does not throw or warn`() { + assertFalse(TrackingUrlDetector.hasTrackingParameter("https://example.com/?%zz=value")) + } +} diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 819fc5cd..467ff05e 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -95,6 +95,7 @@ val sharedSourceIncludes = listOf( "com/bitchat/android/util/ByteArrayExtensions.kt", "com/bitchat/android/util/ByteArrayWrapper.kt", "com/bitchat/android/util/BinaryEncodingUtils.kt", + "com/bitchat/android/util/TrackingUrlDetector.kt", ) val sharedSourceExcludes = listOf( "com/bitchat/android/model/FileSharingManager.kt", @@ -131,6 +132,7 @@ val syncSharedAppTests = tasks.register("syncSharedAppTests") { include( "android/**", "com/bitchat/android/mesh/**", + "com/bitchat/android/util/TrackingUrlDetectorTest.kt", "com/bitchat/FileTransferTest.kt", ) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt index f73891f9..878bdfb2 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -26,6 +28,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,10 +38,16 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.wear.compose.material3.Icon import androidx.wear.compose.material3.IconButton import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TextButton +import androidx.compose.ui.res.stringResource +import com.bitchat.android.util.TrackingUrlDetector +import com.bitchat.watch.R import com.bitchat.watch.ui.theme.ChatVisualTokens import com.bitchat.watch.ui.theme.LocalBitchatPalette @@ -50,10 +59,30 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette fun TextInputScreen(onSend: (String) -> Unit) { val palette = LocalBitchatPalette.current val context = androidx.compose.ui.platform.LocalContext.current - var text by remember { mutableStateOf("") } + var text by rememberSaveable { mutableStateOf("") } + var pendingText by rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current + fun sendNow(message: String) { + keyboardController?.hide() + WearHaptics.tick(context) + onSend(message) + text = "" + } + + fun requestSend(message: String) { + val trimmed = message.trim() + if (trimmed.isEmpty()) return + if (TrackingUrlDetector.containsTrackingUrl(trimmed)) { + keyboardController?.hide() + text = trimmed + pendingText = trimmed + } else { + sendNow(trimmed) + } + } + val dictationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> @@ -62,20 +91,13 @@ fun TextInputScreen(onSend: (String) -> Unit) { ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) ?.firstOrNull() if (!spoken.isNullOrBlank()) { - WearHaptics.tick(context) - onSend(spoken.trim()) + requestSend(spoken) } } } fun send() { - val trimmed = text.trim() - if (trimmed.isNotEmpty()) { - keyboardController?.hide() - WearHaptics.tick(context) - onSend(trimmed) - text = "" - } + requestSend(text) } LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -156,4 +178,45 @@ fun TextInputScreen(onSend: (String) -> Unit) { } } } + + pendingText?.let { message -> + Dialog( + onDismissRequest = { pendingText = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.tracking_link_warning_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tracking_link_send_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { pendingText = null }) { + Text(stringResource(R.string.no)) + } + TextButton( + onClick = { + pendingText = null + sendNow(message) + }, + ) { + Text(stringResource(R.string.yes)) + } + } + } + } + } } diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 2a2d1beb..672b2f09 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -12,6 +12,10 @@ Encrypted direct-message alerts New encrypted message Unlock to view the message + Possible tracker detected + Your link probably contains a tracker. Proceed? + Yes + No %1$d new message %1$d new messages