Preserve waveform colors and stabilize delivery metadata

This commit is contained in:
callebtc 2026-09-08 00:21:25 +03:00
parent 45c8f6625c
commit 1b44e72ecf
9 changed files with 213 additions and 55 deletions

View File

@ -96,7 +96,7 @@ private fun fixtureMessage(id: String, sender: String, peer: String, content: St
timestamp = Date(0), isPrivate = privateChat,
deliveryStatus = if (peer == "synthetic-self") DeliveryStatus.Sent else null)
private fun syntheticWave(): ByteArray {
internal fun syntheticWave(): ByteArray {
val rate = 16_000
val bytes = rate * 2
return ByteBuffer.allocate(44 + bytes).order(ByteOrder.LITTLE_ENDIAN).apply {

View File

@ -0,0 +1,145 @@
package com.bitchat.android.ui
import android.graphics.Bitmap
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.VoiceNotePlayer
import com.bitchat.android.ui.theme.BitchatTheme
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ChatVisualStabilityInstrumentedTest {
@get:Rule val compose = createComposeRule()
@Test fun deliveryChangesKeepMessageTimestampAndMediaBoundsStable() {
val app = InstrumentationRegistry.getInstrumentation().targetContext
val audio = File(app.cacheDir, "synthetic-stability.wav").apply { writeBytes(syntheticWave()) }
val image = File(app.cacheDir, "synthetic-stability.png")
Bitmap.createBitmap(80, 40, Bitmap.Config.ARGB_8888).also { bitmap ->
image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
}
val file = File(app.cacheDir, "synthetic-stability.txt").apply { writeText("Synthetic attachment") }
val contents = mapOf(BitchatMessageType.Message to "Synthetic note.", BitchatMessageType.Audio to audio.path,
BitchatMessageType.Image to image.path, BitchatMessageType.File to file.path)
var textBody by mutableStateOf("Synthetic note.")
var cancelled = 0
var type by mutableStateOf(BitchatMessageType.Message)
var status by mutableStateOf<DeliveryStatus?>(null)
var bubbles by mutableStateOf(true)
var width by mutableStateOf(320)
var self by mutableStateOf(true)
val timestamp = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()).format(Date(0))
compose.setContent {
BitchatTheme(darkTheme = true) {
Surface(Modifier.width(width.dp).fillMaxHeight().testTag("capture")) {
Column(Modifier.padding(16.dp)) {
MessageItem(
BitchatMessage(id = "synthetic-stability", sender = if (self) "me" else "alice",
senderPeerID = if (self) "synthetic-self" else "synthetic-other", content = if (type == BitchatMessageType.Message) textBody else contents.getValue(type),
type = type, timestamp = Date(0), isPrivate = true, deliveryStatus = status),
"me", syntheticMesh(), bubbles = bubbles, onCancelTransfer = { cancelled++ }, modifier = Modifier.testTag("row"),
)
Text("Next message", Modifier.testTag("next"))
}
}
}
}
val statuses = listOf(null, DeliveryStatus.Sending, DeliveryStatus.PartiallyDelivered(1, 3), DeliveryStatus.Sent,
DeliveryStatus.Delivered("synthetic-peer", Date(0)), DeliveryStatus.Read("synthetic-peer", Date(0)), DeliveryStatus.Failed("synthetic"))
val differences = mutableListOf<String>()
val evidence = File(app.filesDir, "chat-ui-stability").apply { mkdirs() }
val record = InstrumentationRegistry.getArguments().getString("recordRegression") == "true"
val cases = contents.keys.map { it to "Synthetic note." } + listOf(
BitchatMessageType.Message to "Synthetic text near a line boundary",
BitchatMessageType.Message to "A longer synthetic message that wraps across several lines to exercise timestamp placement.",
)
for (logicalWidth in listOf(320, 411)) for (bubble in listOf(true, false)) for (own in listOf(true, false)) for ((kind, body) in cases) {
compose.runOnIdle { width = logicalWidth; bubbles = bubble; self = own; type = kind; textBody = body; status = null }
if (kind == BitchatMessageType.Audio) compose.waitUntil(10_000) { compose.onAllNodes(hasStateDescription("Audio waveform")).fetchSemanticsNodes().size == 1 }
if (kind == BitchatMessageType.Image) compose.waitUntil(10_000) { compose.onAllNodesWithContentDescription("Image").fetchSemanticsNodes().size == 1 }
if (kind == BitchatMessageType.File) compose.waitUntil(10_000) { compose.onAllNodesWithText(file.name).fetchSemanticsNodes().size == 1 }
fun bounds(): Map<String, String> {
val result = mutableMapOf("row" to compose.onNodeWithTag("row").fetchSemanticsNode().boundsInRoot.toString(),
"next" to compose.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.toString(),
"timestamp" to compose.onNode(hasText(timestamp, substring = true), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString())
val media = when (kind) {
BitchatMessageType.Audio -> hasStateDescription("Audio waveform")
BitchatMessageType.Image -> hasContentDescription("Image")
BitchatMessageType.File -> hasText(file.name)
else -> hasText(body, substring = true)
}
result["content"] = compose.onNode(media, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString()
return result
}
val initial = bounds()
for ((index, nextStatus) in statuses.withIndex()) {
// Image/file transfer animations intentionally replace their content; receipt ticks do not.
if (nextStatus is DeliveryStatus.PartiallyDelivered && kind != BitchatMessageType.Audio) continue
compose.runOnIdle { status = nextStatus }
compose.waitForIdle()
if (own && kind == BitchatMessageType.Audio && nextStatus is DeliveryStatus.PartiallyDelivered) {
val previous = cancelled
compose.onNodeWithContentDescription("Cancel").performClick()
compose.runOnIdle { assertTrue("Cancel callback must remain wired", cancelled == previous + 1) }
}
val current = bounds()
val label = "$logicalWidth-${if (bubble) "bubbles" else "matrix"}-${if (own) "sent" else "received"}-$kind-$index"
initial.forEach { (node, rect) -> if (current[node] != rect) differences += "$label bodyLength=${body.length} $node: $rect -> ${current[node]}" }
if (logicalWidth == 320 && own && kind == BitchatMessageType.Audio && index in listOf(0, 2, 3, 5)) {
File(evidence, "${if (record) "before" else "after"}-$label.png").outputStream().use {
compose.onNodeWithTag("capture").captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it)
}
}
}
}
File(evidence, "${if (record) "before" else "after"}-bounds.txt").writeText(differences.joinToString("\n"))
assertTrue("Delivery status displaced content:\n${differences.take(20).joinToString("\n")}", record || differences.isEmpty())
}
@Test fun playerKeepsGreenPlaybackAndBlueTransferColors() {
val app = InstrumentationRegistry.getInstrumentation().targetContext
val audio = File(app.cacheDir, "synthetic-color.wav").apply { writeBytes(syntheticWave()) }
VoiceWaveformCache.put(audio.path, FloatArray(120) { 0.8f })
var transfer by mutableStateOf<Float?>(null)
compose.setContent {
BitchatTheme(darkTheme = true) {
Surface(color = Color.Black) { VoiceNotePlayer(audio.path, Modifier.width(300.dp), progressOverride = transfer) }
}
}
compose.waitUntil(10_000) { compose.onAllNodesWithText("00:01").fetchSemanticsNodes().size == 1 }
val waveform = compose.onNode(hasStateDescription("Audio waveform"))
fun hasColor(red: Float, green: Float, blue: Float): Boolean {
val pixels = waveform.captureToImage().toPixelMap()
return (0 until pixels.width).any { x -> (0 until pixels.height).any { y ->
val p = pixels[x, y]
kotlin.math.abs(p.red - red) < 0.025f && kotlin.math.abs(p.green - green) < 0.025f && kotlin.math.abs(p.blue - blue) < 0.025f
} }
}
assertTrue("Unplayed waveform must retain translucent green", hasColor(0f, 34f / 255f, 17f / 255f))
waveform.performTouchInput { click(center) }
assertTrue("Playback progress must remain green", hasColor(0f, 200f / 255f, 81f / 255f))
compose.runOnIdle { transfer = 0.5f }
assertTrue("Transfer progress must remain blue", hasColor(30f / 255f, 136f / 255f, 229f / 255f))
}
}

View File

@ -883,24 +883,15 @@ private fun BubbleTextMessageLayout(
)
if (isSelf) {
Row(
verticalAlignment = Alignment.CenterVertically,
MessageMetadata(
message = message,
timeFormatter = timeFormatter,
showDeliveryStatus = message.isPrivate,
modifier = Modifier
.align(Alignment.BottomEnd)
.onSizeChanged { clusterSize = it }
.graphicsLayer { alpha = if (metaPlan != null) 1f else 0f },
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
)
if (message.isPrivate) {
message.deliveryStatus?.let { status ->
Spacer(Modifier.width(4.dp))
DeliveryStatusIcon(status = status)
}
}
}
)
}
}
}

View File

@ -0,0 +1,40 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.theme.BitchatFontFamily
import java.text.SimpleDateFormat
/** Reserve the receipt slot before the first status arrives, for text and media alike. */
@Composable
internal fun MessageMetadata(
message: BitchatMessage,
timeFormatter: SimpleDateFormat,
showDeliveryStatus: Boolean,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(text = formatTextMessageMetadata(message, timeFormatter), fontFamily = BitchatFontFamily)
if (showDeliveryStatus) {
Spacer(Modifier.width(4.dp))
val status = message.deliveryStatus
Box(
Modifier.graphicsLayer { alpha = if (status == null) 0f else 1f }
.then(if (status == null) Modifier.clearAndSetSemantics {} else Modifier),
) {
DeliveryStatusIcon(status ?: DeliveryStatus.Sending)
}
}
}
}

View File

@ -6,7 +6,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -47,17 +46,12 @@ fun AudioMessageItem(
color = MaterialTheme.colorScheme.primary,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
VoiceNotePlayer(
path = message.content.trim(),
isLive = isLive,
progressOverride = progress,
modifier = Modifier.weight(1f),
)
if (progress != null && onCancelTransfer != null) {
CancelMediaTransferButton { onCancelTransfer(message) }
}
}
VoiceNotePlayer(
path = message.content.trim(),
isLive = isLive,
progressOverride = progress,
onCancelTransfer = onCancelTransfer?.let { cancel -> { cancel(message) } },
)
}
}
}

View File

@ -7,13 +7,9 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -24,8 +20,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.DeliveryStatusIcon
import com.bitchat.android.ui.formatTextMessageMetadata
import com.bitchat.android.ui.MessageMetadata
import com.bitchat.android.ui.formatTextMessageSender
import com.bitchat.android.ui.isFromSelf
import com.bitchat.android.ui.peerIdentityForMessage
@ -134,21 +129,12 @@ fun MediaBubbleShell(
content()
Row(
verticalAlignment = Alignment.CenterVertically,
MessageMetadata(
message = message,
timeFormatter = timeFormatter,
showDeliveryStatus = isSelf && message.isPrivate,
modifier = Modifier.align(Alignment.End),
) {
Text(
text = formatTextMessageMetadata(message, timeFormatter),
fontFamily = BitchatFontFamily,
)
if (isSelf && message.isPrivate) {
message.deliveryStatus?.let { status ->
Spacer(Modifier.width(4.dp))
DeliveryStatusIcon(status = status)
}
}
}
)
}
}
}

View File

@ -79,8 +79,8 @@ internal fun MediaMessageLayout(
}
@Composable
internal fun CancelMediaTransferButton(onClick: () -> Unit) {
IconButton(onClick = onClick) {
internal fun CancelMediaTransferButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = modifier) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.cd_cancel))
}
}

View File

@ -26,7 +26,8 @@ fun VoiceNotePlayer(
modifier: Modifier = Modifier,
progressOverride: Float? = null,
progressColor: Color? = null,
isLive: Boolean = false
isLive: Boolean = false,
onCancelTransfer: (() -> Unit)? = null,
) {
var isPlaying by remember { mutableStateOf(false) }
var isPrepared by remember { mutableStateOf(false) }
@ -95,13 +96,14 @@ fun VoiceNotePlayer(
) {
// Disable play/pause while showing send progress override (optional UX choice)
val controlsEnabled = isPrepared && !isError && !isLive && progressOverride == null
FilledTonalIconButton(onClick = { if (controlsEnabled) isPlaying = !isPlaying }, enabled = controlsEnabled, modifier = Modifier.size(28.dp)) {
if (progressOverride != null && onCancelTransfer != null) {
CancelMediaTransferButton(onClick = onCancelTransfer, modifier = Modifier.size(28.dp))
} else FilledTonalIconButton(onClick = { if (controlsEnabled) isPlaying = !isPlaying }, enabled = controlsEnabled, modifier = Modifier.size(28.dp)) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = stringResource(if (isPlaying) R.string.cd_pause_voice else R.string.cd_play_voice)
)
}
val progressBarColor = progressColor ?: MaterialTheme.colorScheme.primary
com.bitchat.android.ui.media.WaveformPreview(
modifier = Modifier
.height(24.dp)
@ -112,7 +114,7 @@ fun VoiceNotePlayer(
playbackProgress = if (progressOverride == null) progress else null,
onSeek = if (controlsEnabled) seekTo else null,
isLive = isLive,
progressColor = progressBarColor
progressColor = progressColor
)
val locale = LocalConfiguration.current.locales[0]
val durText = if (isError && !isLive) stringResource(R.string.voice_unavailable) else if (durationMs > 0) String.format(locale, "%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"

View File

@ -98,8 +98,8 @@ fun WaveformPreview(
modifier = modifier.semantics { stateDescription = description },
samples = stateSamples.ifEmpty { List(40) { 0.08f } },
fillProgress = if (stateSamples.isEmpty()) 0f else progress,
baseColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f),
fillColor = progressColor ?: MaterialTheme.colorScheme.primary,
baseColor = Color(0x2200FF7F),
fillColor = progressColor ?: if (sendProgress != null) Color(0xFF1E88E5) else Color(0xFF00C851),
onSeek = if (isLive) null else onSeek
)
}