mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge remote-tracking branch 'origin/main' into codex/background-power-optimization
This commit is contained in:
commit
a06c9f8614
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user