diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt index 42e207e4..eb654258 100644 --- a/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt @@ -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 { diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt new file mode 100644 index 00000000..af132278 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt @@ -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(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() + 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 { + 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(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)) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index f58e78de..40e478fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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) - } - } - } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt b/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt new file mode 100644 index 00000000..fc0732be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 03e8bb26..75db2e24 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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) } }, + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt index bc69dec3..e372cfa3 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt @@ -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) - } - } - } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt index 8666671b..78a5f9b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt @@ -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)) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index f663d4ca..f37c5c0b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -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 "--:--" diff --git a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt index 327b426f..d272e6c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt @@ -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 ) }