From 85cbe20b3902e249fc3e1d3c78e5c7f1cffba0b3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:56:48 +0200 Subject: [PATCH 1/3] fix: use authenticated timestamps for Nostr DMs --- .../nostr/NostrDirectMessageHandler.kt | 9 +- .../nostr/NostrDirectMessageHandlerTest.kt | 179 ++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 7a49057d..469972c8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -31,11 +31,14 @@ class NostrDirectMessageHandler( private val meshDelegateHandler: MeshDelegateHandler, private val scope: CoroutineScope, private val repo: GeohashRepository, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val seenStoreProvider: () -> SeenMessageStore = { + SeenMessageStore.getInstance(application) + } ) { companion object { private const val TAG = "NostrDirectMessageHandler" } - private val seenStore by lazy { SeenMessageStore.getInstance(application) } + private val seenStore by lazy(seenStoreProvider) // Simple event deduplication private val processedIds = ArrayDeque() @@ -82,7 +85,7 @@ class NostrDirectMessageHandler( if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(giftWrap.createdAt * 1000L) + val messageTimestamp = Date(rumorTimestamp * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt new file mode 100644 index 00000000..738b3231 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -0,0 +1,179 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import com.google.gson.Gson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrDirectMessageHandlerTest { + private val gson = Gson() + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + AppStateStore.clear() + } + + @After + fun tearDown() { + AppStateStore.clear() + scope.cancel() + Dispatchers.resetMain() + } + + @Test + fun `private messages use authenticated rumor time instead of randomized gift wrap time`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val messageManager = MessageManager(state) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasRead(any())).thenReturn(false) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + meshDelegateHandler = mock(), + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val firstRumorTime = now - 120 + val secondRumorTime = now - 60 + val firstId = "first-real-time" + val secondId = "second-real-time" + + val first = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "first", + messageID = firstId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = firstRumorTime, + giftWrapCreatedAt = now - 5 + ) + val second = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "second", + messageID = secondId, + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = secondRumorTime, + giftWrapCreatedAt = now - 86_400 + ) + + handler.onGiftWrap(first, "", recipient) + waitForMessage(state, firstId) + handler.onGiftWrap(second, "", recipient) + waitForMessage(state, secondId) + + val messages = state.getPrivateChatsValue().values.single() + assertEquals(listOf(firstId, secondId), messages.map { it.id }) + assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time) + assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) + } + + private fun waitForMessage(state: ChatState, messageId: String) { + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { + while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) { + delay(10) + } + } + } + } + + private fun privateMessageGiftWrap( + content: String, + sender: NostrIdentity, + recipient: NostrIdentity, + rumorCreatedAt: Int, + giftWrapCreatedAt: Int + ): NostrEvent { + val rumorBase = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = rumorCreatedAt, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = content + ) + val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex()) + val sealContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(rumor), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = sender.privateKeyHex + ) + val seal = NostrEvent( + pubkey = sender.publicKeyHex, + createdAt = giftWrapCreatedAt, + kind = NostrKind.SEAL, + tags = emptyList(), + content = sealContent + ).sign(sender.privateKeyHex) + + val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() + val giftWrapContent = NostrCrypto.encryptNIP44( + plaintext = gson.toJson(seal), + recipientPublicKeyHex = recipient.publicKeyHex, + senderPrivateKeyHex = wrapPrivateKey + ) + return NostrEvent( + pubkey = wrapPublicKey, + createdAt = giftWrapCreatedAt, + kind = NostrKind.GIFT_WRAP, + tags = listOf(listOf("p", recipient.publicKeyHex)), + content = giftWrapContent + ).sign(wrapPrivateKey) + } +} From 81a35d9dba0641ae50a5526f538a6a19c5d51f49 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:58:36 +0200 Subject: [PATCH 2/3] ui: use shared green CloseButton in private and channel headers Align conversation exit controls with bottom-sheet close chrome so the X reads as primary green rather than muted grey. Co-authored-by: Cursor --- .../main/java/com/bitchat/android/ui/ChatHeader.kt | 13 ++----------- .../com/bitchat/android/ui/MeshPeerListSheet.kt | 12 +----------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index af38ea1d..fe3ad343 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.RowScope import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton +import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode import com.bitchat.android.ui.theme.BitchatMotion @@ -620,17 +621,7 @@ private fun ChannelHeader( title = "#$channel", onTitleClick = onSidebarClick ) { - ConversationHeaderAction( - onClick = onBackClick, - contentDescription = stringResource(R.string.close_plain) - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_close), - contentDescription = stringResource(R.string.close_plain), - modifier = Modifier.size(HeaderIconSize), - tint = colorScheme.onSurfaceVariant - ) - } + CloseButton(onClick = onBackClick) } } 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 d9a8d035..c496938e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1024,17 +1024,7 @@ fun PrivateChatSheet( } val dismiss = LocalSheetDismiss.current - ConversationHeaderAction( - onClick = { dismiss?.invoke() ?: onDismiss() }, - contentDescription = stringResource(R.string.close_plain) - ) { - Icon( - painter = painterResource(R.drawable.ic_spec_close), - contentDescription = null, - modifier = Modifier.size(HeaderIconSize), - tint = colorScheme.onSurfaceVariant - ) - } + CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() }) } } } From 1a390b5a1f59495986f2db1e0ab1833cffe13c04 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:13:46 +0200 Subject: [PATCH 3/3] ui: glow lock for Noise handshake instead of sync icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match private-chat Noise status to the Tor globe treatment: one lock glyph with an orange pulse while handshaking, then green or red with smooth tint cross-fades — no sync/recycle swap. Co-authored-by: Cursor --- .../java/com/bitchat/android/ui/ChatHeader.kt | 97 ++++++++++++------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index fe3ad343..8d4fdfa1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween @@ -157,7 +158,8 @@ internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { /** * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout - * does not shift; only opacity / a faint halo breathe. + * does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so + * starting/stopping progress never pops. */ @Composable internal fun TorAwareHeaderIcon( @@ -167,7 +169,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torGlow") transition.animateFloat( initialValue = 0.42f, @@ -188,7 +195,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -201,7 +208,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -211,7 +218,9 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + // Idle = solid; in-progress = breathing opacity, lerped by [progressFade]. + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) @@ -227,7 +236,12 @@ internal fun TorAwareHeaderIcon( contentDescription: String?, modifier: Modifier = Modifier, ) { - val pulse = if (isProgress) { + val progressFade by animateFloatAsState( + targetValue = if (isProgress) 1f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), + label = "torPainterGlowFade" + ) + val pulse = if (progressFade > 0.01f) { val transition = rememberInfiniteTransition(label = "torPainterGlow") transition.animateFloat( initialValue = 0.42f, @@ -246,7 +260,7 @@ internal fun TorAwareHeaderIcon( contentAlignment = Alignment.Center, modifier = modifier.size(HeaderIconSize) ) { - if (isProgress) { + if (progressFade > 0.01f) { val glowBrush = remember(tint) { Brush.radialGradient( colorStops = arrayOf( @@ -259,7 +273,7 @@ internal fun TorAwareHeaderIcon( Box( modifier = Modifier .requiredSize(HeaderIconSize + 14.dp) - .graphicsLayer { alpha = pulse * 0.85f } + .graphicsLayer { alpha = pulse * 0.85f * progressFade } .background(glowBrush) ) } @@ -269,13 +283,22 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + val breathing = 0.55f + pulse * 0.45f + alpha = 1f - progressFade * (1f - breathing) }, tint = tint ) } } +/** + * Noise session status for private-chat headers. + * + * Same visual language as the main header's Tor-aware globe: one lock glyph throughout, tint + * cross-fades between states, and a soft radial glow pulse while the handshake is in flight. + * The old sync/recycle glyph is gone — progress is carried by colour and motion, not by swapping + * icons. + */ @Composable fun NoiseSessionIcon( sessionState: String?, @@ -283,39 +306,45 @@ fun NoiseSessionIcon( ) { val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme - // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with - // an all-but-transparent RGB - the icons were effectively invisible. They now use the - // palette's secondary text colour. - val (iconRes, color, contentDescription) = when (sessionState) { - "uninitialized" -> Triple( - R.drawable.ic_spec_lock_open, - colorScheme.onSurfaceVariant, - stringResource(R.string.cd_ready_for_handshake) - ) - "handshaking" -> Triple( - R.drawable.ic_spec_sync, - colorScheme.onSurfaceVariant, + + val (targetTint, isProgress, contentDescription) = when { + sessionState == "handshaking" -> Triple( + palette.accentOrange, + true, stringResource(R.string.cd_handshake_in_progress) ) - "established" -> Triple( - R.drawable.ic_spec_lock, + sessionState == "established" -> Triple( colorScheme.primary, + false, stringResource(R.string.cd_encrypted) ) - else -> { // "failed" or any other state - Triple( - R.drawable.ic_spec_warning, - colorScheme.error, - stringResource(R.string.cd_handshake_failed) - ) - } + sessionState?.startsWith("failed") == true -> Triple( + colorScheme.error, + false, + stringResource(R.string.cd_handshake_failed) + ) + else -> Triple( + // Not yet started — quiet grey lock, same glyph as every other state. + colorScheme.onSurfaceVariant, + false, + stringResource(R.string.cd_ready_for_handshake) + ) } - Icon( - painter = painterResource(iconRes), + // Longer than the usual chrome tint so grey → orange → green reads as a continuous wash, + // not a snap between discrete states. + val animatedTint by animateColorAsState( + targetValue = targetTint, + animationSpec = tween(durationMillis = 480, easing = FastOutSlowInEasing), + label = "noiseSessionTint" + ) + + TorAwareHeaderIcon( + painter = painterResource(R.drawable.ic_spec_lock), + tint = animatedTint, + isProgress = isProgress, contentDescription = contentDescription, - modifier = modifier, - tint = color + modifier = modifier ) }