mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge 01af0b13703b138f55cb6edfc08d62b941397886 into 094657efa0aabbb6f71c9050149d1d01aee96400
This commit is contained in:
commit
15c95d7734
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String?>(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<String?>(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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, "")
|
||||
|
||||
@ -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<String?>(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
|
||||
}
|
||||
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -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 = """(?<![\w@.-])(?:https?://|www\.)[^\s<>"']+|(?<![\w@.-])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s<>"']*)?""",
|
||||
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)
|
||||
}
|
||||
@ -136,6 +136,14 @@
|
||||
<string name="private_media_legacy_body" tools:ignore="MissingTranslation">%1$s cannot be sent encrypted to %2$s because their client is older. %3$s</string>
|
||||
<string name="private_media_legacy_send_once" tools:ignore="MissingTranslation">Send this file once</string>
|
||||
|
||||
<!-- Tracking-link warnings -->
|
||||
<string name="tracking_link_warning_title" tools:ignore="MissingTranslation">Possible tracker detected</string>
|
||||
<string name="tracking_link_send_warning" tools:ignore="MissingTranslation">Your link probably contains a tracker. Proceed?</string>
|
||||
<string name="tracking_link_open_warning" tools:ignore="MissingTranslation">This link probably contains a tracker. Open the link?</string>
|
||||
<string name="tracking_link_reply_saved" tools:ignore="MissingTranslation">Possible tracker detected. Reply saved as a draft for review.</string>
|
||||
<string name="yes" tools:ignore="MissingTranslation">Yes</string>
|
||||
<string name="no" tools:ignore="MissingTranslation">No</string>
|
||||
|
||||
<!-- File viewer dialog -->
|
||||
<string name="file_viewer_title">📎 File Received</string>
|
||||
<string name="file_viewer_name">📄 %1$s</string>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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<Sync>("syncSharedAppTests") {
|
||||
include(
|
||||
"android/**",
|
||||
"com/bitchat/android/mesh/**",
|
||||
"com/bitchat/android/util/TrackingUrlDetectorTest.kt",
|
||||
"com/bitchat/FileTransferTest.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<String?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,10 @@
|
||||
<string name="message_channel_description">Encrypted direct-message alerts</string>
|
||||
<string name="notification_new_private_message">New encrypted message</string>
|
||||
<string name="notification_private_message_hidden">Unlock to view the message</string>
|
||||
<string name="tracking_link_warning_title">Possible tracker detected</string>
|
||||
<string name="tracking_link_send_warning">Your link probably contains a tracker. Proceed?</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
<plurals name="notification_private_message_summary">
|
||||
<item quantity="one">%1$d new message</item>
|
||||
<item quantity="other">%1$d new messages</item>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user