Merge 01af0b13703b138f55cb6edfc08d62b941397886 into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
farwhile 2026-08-05 12:54:14 +00:00 committed by GitHub
commit 15c95d7734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 494 additions and 29 deletions

View File

@ -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.

View File

@ -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
}
}

View File

@ -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 },
)
}
}
/**

View File

@ -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)

View File

@ -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 },
)
}
}
/**

View File

@ -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, "")

View File

@ -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
}

View File

@ -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 =

View File

@ -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))
}
},
)
}

View File

@ -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)
}

View File

@ -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>

View File

@ -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))
}
}

View File

@ -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&amp;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"))
}
}

View File

@ -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",
)
}

View File

@ -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))
}
}
}
}
}
}

View File

@ -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>