Merge remote-tracking branch 'origin/main' into codex/background-power-optimization

This commit is contained in:
callebtc 2026-07-27 18:26:49 +02:00
commit a06c9f8614
4 changed files with 251 additions and 59 deletions

View File

@ -30,11 +30,14 @@ class NostrDirectMessageHandler(
private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit,
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<String>()
@ -81,7 +84,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)

View File

@ -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
@ -47,6 +48,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
@ -156,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(
@ -166,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,
@ -187,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(
@ -200,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)
)
}
@ -210,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
)
@ -226,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,
@ -245,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(
@ -258,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)
)
}
@ -268,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?,
@ -282,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
)
}
@ -620,17 +650,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)
}
}

View File

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

View File

@ -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<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
whenever(seenStore.hasDelivered(any())).thenReturn(true)
whenever(seenStore.hasRead(any())).thenReturn(false)
val handler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
meshDelegateHandler = mock<MeshDelegateHandler>(),
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)
}
}