Merge 1b44e72ecfbec43ba1aca0ab7c06c2b75527b621 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
callebtc 2026-09-14 13:09:11 -07:00 committed by GitHub
commit 49b7337e38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1315 additions and 856 deletions

View File

@ -38,7 +38,7 @@ android {
"\"$normalizedGithubReleaseCertSha256\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.bitchat.android.ui.ChatUiTestRunner"
vectorDrawables {
useSupportLibrary = true
}
@ -194,7 +194,10 @@ dependencies {
testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.bundles.compose.testing)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.espresso.core)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).

View File

@ -58,7 +58,7 @@ androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAnd
androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@ -74,10 +74,10 @@ androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debu
androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@ -144,25 +144,21 @@ androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debug
androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath
androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath
androidx.test.espresso:espresso-core:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:annotation:1.0.1=debugAndroidTestLintChecksClasspath
androidx.test:core:1.5.0=debugAndroidTestLintChecksClasspath
androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugUnitTestCompileClasspath
androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@ -248,7 +244,7 @@ com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintCheck
com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
@ -288,7 +284,7 @@ com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debug
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
@ -366,8 +362,8 @@ org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debu
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease

View File

@ -0,0 +1,111 @@
package com.bitchat.android.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asAndroidBitmap
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.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.theme.BitchatTheme
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Date
import kotlin.math.sin
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** Identical synthetic rows can also be run against the base revision for visual comparison. */
class ChatMediaRenderingInstrumentedTest {
@get:Rule val compose = createComposeRule()
@Test fun finalizedVoiceAndAttachmentsRenderAcrossChatModes() {
val app = InstrumentationRegistry.getInstrumentation().targetContext
val fixture = File(app.cacheDir, "chat-ui-fixture").apply { mkdirs() }
val before = File(fixture, "synthetic-live.aac").apply { writeBytes(byteArrayOf()) }
val finished = File(fixture, "synthetic-finished.wav").apply { writeBytes(syntheticWave()) }
val sent = File(fixture, "synthetic-sent.wav").apply { writeBytes(syntheticWave()) }
val photo = File(fixture, "synthetic-image.png")
val bitmap = Bitmap.createBitmap(480, 240, Bitmap.Config.ARGB_8888)
Canvas(bitmap).apply {
drawColor(android.graphics.Color.rgb(30, 80, 110))
drawCircle(320f, 80f, 50f, Paint().apply { color = android.graphics.Color.rgb(240, 180, 70) })
}
photo.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
val attachment = File(fixture, "synthetic-note.txt").apply { writeText("Synthetic attachment") }
var path by mutableStateOf(before.path)
var privateChat by mutableStateOf(true)
var bubbles by mutableStateOf(true)
var dark by mutableStateOf(true)
val baseline = InstrumentationRegistry.getArguments().getString("baseline") == "true"
compose.setContent {
BitchatTheme(darkTheme = dark) {
Surface(Modifier.fillMaxSize().testTag("capture")) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(if (privateChat) "Private chat · synthetic fixture" else "Public mesh · synthetic fixture")
val messages = listOf(
fixtureMessage("received-voice", "alice", "synthetic-other", path, BitchatMessageType.Audio, privateChat),
fixtureMessage("sent-voice", "previous-name", "synthetic-self", sent.path, BitchatMessageType.Audio, privateChat),
fixtureMessage("image", "alice", "synthetic-other", photo.path, BitchatMessageType.Image, privateChat),
fixtureMessage("file", "me", "synthetic-self", attachment.path, BitchatMessageType.File, privateChat),
)
messages.forEach { message ->
key(message.id) {
MessageItem(message, "me", syntheticMesh(), messages = messages, bubbles = bubbles)
}
}
}
}
}
}
compose.waitForIdle()
compose.runOnIdle { path = finished.path }
compose.waitUntil(15_000) { compose.onAllNodesWithText("00:01").fetchSemanticsNodes().size == 2 }
if (!baseline) {
compose.waitUntil(15_000) { compose.onAllNodes(hasStateDescription("Audio waveform")).fetchSemanticsNodes().size == 2 }
}
val evidence = File(app.filesDir, "chat-ui-evidence").apply { mkdirs() }
File(evidence, "${if (baseline) "before" else "after"}-voice-replacement.png").outputStream().use {
assertTrue(compose.onNodeWithTag("capture").captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it))
}
for (private in listOf(false, true)) for (bubble in listOf(false, true)) for (night in listOf(false, true)) {
compose.runOnIdle { privateChat = private; bubbles = bubble; dark = night }
compose.waitForIdle()
val name = "${if (baseline) "before" else "after"}-${if (private) "private" else "public"}-${if (bubble) "bubbles" else "matrix"}-${if (night) "dark" else "light"}.png"
File(evidence, name).outputStream().use {
assertTrue(compose.onNodeWithTag("capture").captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it))
}
}
}
}
private fun fixtureMessage(id: String, sender: String, peer: String, content: String, type: BitchatMessageType, privateChat: Boolean) =
BitchatMessage(id = id, sender = sender, senderPeerID = peer, content = content, type = type,
timestamp = Date(0), isPrivate = privateChat,
deliveryStatus = if (peer == "synthetic-self") DeliveryStatus.Sent else null)
internal fun syntheticWave(): ByteArray {
val rate = 16_000
val bytes = rate * 2
return ByteBuffer.allocate(44 + bytes).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray()); putInt(36 + bytes); put("WAVEfmt ".toByteArray())
putInt(16); putShort(1); putShort(1); putInt(rate); putInt(rate * 2); putShort(2); putShort(16)
put("data".toByteArray()); putInt(bytes)
repeat(rate) { index ->
val envelope = 0.2 + 0.8 * (0.5 + 0.5 * sin(index * 12.0 / rate))
putShort((sin(index * 2.0 * Math.PI * 440.0 / rate) * envelope * 20_000).toInt().toShort())
}
}.array()
}

View File

@ -0,0 +1,126 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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 com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.WaveformPreview
import com.bitchat.android.ui.theme.BitchatTheme
import java.lang.reflect.Proxy
import java.util.Date
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import android.graphics.Bitmap
import java.io.File
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.ui.theme.ChatUiMode
import com.bitchat.android.ui.theme.ChatUiModeManager
class ChatUiParityInstrumentedTest {
@get:Rule val compose = createComposeRule()
@Test fun waveformReloadsWhenBothOldAndNewSourcesAreUncached() {
var path by mutableStateOf("live")
var live by mutableStateOf(false)
val old = CompletableDeferred<List<Float>?>()
val loaded = mutableListOf<String>()
compose.setContent {
BitchatTheme {
WaveformPreview(
Modifier.fillMaxWidth().height(32.dp), path, null, null,
isLive = live,
loadSamples = { source ->
loaded.add(source)
if (source == "live") old.await() else List(120) { 0.6f }
},
)
}
}
compose.onNode(hasStateDescription("Loading waveform")).assertExists()
compose.runOnIdle { path = "finished" }
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
compose.runOnIdle { old.complete(List(120) { 0f }) }
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
assertEquals(listOf("live", "finished"), loaded)
compose.runOnIdle { live = true }
compose.onNode(hasStateDescription("Live audio")).assertExists()
compose.runOnIdle { live = false }
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
assertEquals(listOf("live", "finished", "finished"), loaded)
}
@Test fun privateTimelineWiresMessageActionsAndJumpToLatest() {
val messages = (0..35).map { index ->
BitchatMessage(id = "synthetic-$index", sender = "alice", content = "Synthetic message $index",
senderPeerID = "synthetic-other", timestamp = Date(index * 60_000L), isPrivate = true)
}
compose.setContent {
BitchatTheme {
Surface {
ConversationTimelineContent(
messages, "me", syntheticMesh(), ConversationUiContext("dm", "synthetic-other"),
onMention = {}, onCancelTransfer = {}, modifier = Modifier.fillMaxSize(),
messageActions = { message, dismiss ->
AlertDialog(onDismissRequest = dismiss, text = { Text(message.content) },
confirmButton = { TextButton(onClick = dismiss) { Text("Close actions") } })
},
)
}
}
}
compose.onNode(hasText("Synthetic message 35", substring = true)).performTouchInput { longClick() }
compose.onNodeWithText("Close actions").assertExists().performClick()
compose.onNode(hasScrollAction()).performScrollToIndex(30)
compose.onNodeWithContentDescription("Scroll to bottom").assertExists().performClick()
compose.onNode(hasText("Synthetic message 35", substring = true)).assertIsDisplayed()
}
@Test fun everyConversationOpensImagesAndMatrixImagesSupportLongPress() {
val app = InstrumentationRegistry.getInstrumentation().targetContext
val file = File(app.cacheDir, "synthetic-gallery.png")
val bitmap = Bitmap.createBitmap(80, 80, Bitmap.Config.ARGB_8888)
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
var context by mutableStateOf(ConversationUiContext("mesh"))
compose.setContent {
BitchatTheme {
Surface {
ConversationTimelineContent(
listOf(BitchatMessage(id = "synthetic-image", sender = "alice", content = file.path,
type = BitchatMessageType.Image, timestamp = Date(0), senderPeerID = "synthetic-other",
isPrivate = context.privatePeerID != null)),
"me", syntheticMesh(), context, onMention = {}, onCancelTransfer = {},
modifier = Modifier.fillMaxSize(),
messageActions = { _, dismiss ->
AlertDialog(onDismissRequest = dismiss, text = { Text("Image actions") },
confirmButton = { TextButton(onClick = dismiss) { Text("Close actions") } })
},
)
}
}
}
for (nostr in listOf(false, true)) for (private in listOf(false, true)) {
compose.runOnIdle {
context = ConversationUiContext("$nostr-$private", if (private) "synthetic-other" else null, nostr)
}
compose.waitUntil(5_000) { compose.onAllNodesWithContentDescription("Image").fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithContentDescription("Image").performClick()
compose.onNodeWithContentDescription("Image 1 of 1").assertExists()
compose.onNodeWithContentDescription("Close").performClick()
}
compose.runOnIdle { ChatUiModeManager.set(app, ChatUiMode.Matrix) }
compose.onNodeWithContentDescription("Image").performTouchInput { longClick() }
compose.onNodeWithText("Image actions").assertExists()
compose.onNodeWithText("Close actions").performClick()
compose.runOnIdle { ChatUiModeManager.set(app, ChatUiMode.Bubbles) }
}
}

View File

@ -0,0 +1,11 @@
package com.bitchat.android.ui
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
/** UI tests never initialize transports, identities, location services or a real conversation store. */
class ChatUiTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, className: String, context: Context): Application =
super.newApplication(cl, Application::class.java.name, context)
}

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

@ -0,0 +1,56 @@
package com.bitchat.android.ui
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.compose.runtime.*
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.content.FileProvider
import androidx.test.platform.app.InstrumentationRegistry
import com.bitchat.android.ui.media.FileAttachment
import com.bitchat.android.ui.media.FileViewerDialog
import com.bitchat.android.ui.theme.BitchatTheme
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
class FileAttachmentDialogInstrumentedTest {
@get:Rule val compose = createComposeRule()
@Test fun saveExportsBytesAndCancellationKeepsTheDialogOpen() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val app = instrumentation.targetContext
val source = File(app.filesDir, "synthetic-export.txt").apply { writeText("Synthetic file export") }
val destination = File(app.filesDir, "synthetic-destination.txt").apply { writeText("") }
val uri = FileProvider.getUriForFile(app, "${app.packageName}.fileprovider", destination)
var cancel = true
var dismissed by mutableStateOf(false)
val monitor = object : Instrumentation.ActivityMonitor() {
override fun onStartActivity(intent: Intent): Instrumentation.ActivityResult? =
if (intent.action == Intent.ACTION_CREATE_DOCUMENT) {
assertEquals("text/plain", intent.type)
if (cancel) Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)
else Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(uri))
} else null
}
instrumentation.addMonitor(monitor)
try {
compose.setContent {
BitchatTheme {
if (!dismissed) FileViewerDialog(FileAttachment.fromPath(source.path)!!) { dismissed = true }
}
}
compose.onNodeWithText("Save").performClick()
compose.onNodeWithText(source.name).assertExists()
assertFalse(dismissed)
compose.runOnIdle { cancel = false }
compose.onNodeWithText("Save").performClick()
compose.waitUntil(5_000) { dismissed }
assertArrayEquals(source.readBytes(), destination.readBytes())
} finally {
instrumentation.removeMonitor(monitor)
}
}
}

View File

@ -0,0 +1,18 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import java.lang.reflect.Proxy
internal fun syntheticMesh(): MeshService = Proxy.newProxyInstance(
MeshService::class.java.classLoader, arrayOf(MeshService::class.java),
) { _, method, _ ->
when (method.name) {
"getMyPeerID" -> "synthetic-self"
"getPeerNicknames" -> emptyMap<String, String>()
"shouldShowEncryptionIcon", "hasEstablishedSession" -> false
"toString" -> "Synthetic mesh"
"hashCode" -> 1
"equals" -> false
else -> null
}
} as MeshService

View File

@ -7,8 +7,6 @@ import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
@ -18,19 +16,14 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.DefaultLifecycleObserver
@ -42,10 +35,8 @@ import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
import com.bitchat.android.ui.theme.BitchatMotion
/**
@ -90,14 +81,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
var passwordInput by remember { mutableStateOf("") }
var showLocationChannelsSheet by remember { mutableStateOf(false) }
var showLocationNotesSheet by remember { mutableStateOf(false) }
var showUserSheet by remember { mutableStateOf(false) }
var selectedUserForSheet by remember { mutableStateOf("") }
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
var showFullScreenImageViewer by remember { mutableStateOf(false) }
var viewerImagePaths by remember { mutableStateOf(emptyList<String>()) }
var initialViewerIndex by remember { mutableStateOf(0) }
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
LaunchedEffect(selectedPrivatePeer) {
messageText = TextFieldValue(
@ -250,11 +234,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
buildMentionPeerIdentityMap(displayMessages, knownPeers)
}
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
val conversationContext = ConversationUiContext(
key = conversationKey,
isNostr = currentChannel == null && selectedLocationChannel is ChannelID.Location,
)
val showMediaButtons = conversationContext.supportsMediaSend(hasMeshRoute = false)
// Use WindowInsets to handle keyboard properly
Box(
@ -298,66 +282,22 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showNotesStrip =
isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
MessagesList(
ConversationTimeline(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
viewModel = viewModel,
mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxSize(),
conversationKey = conversationKey,
context = conversationContext,
contentPadding = PaddingValues(
top = statusBarHeight + headerHeight +
(if (showNotesStrip) notesStripHeight else 0.dp),
bottom = composerHeight
),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (
selectedLocationChannel is ChannelID.Location &&
hashSuffix.isNotEmpty()
) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
onMention = { sender ->
messageText = appendConversationMention(messageText, sender, conversationContext)
viewModel.setConversationDraft(null, messageText.text)
}
)
@ -465,52 +405,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
)
// Scroll-to-bottom floating button
AnimatedVisibility(
visible = isScrolledUp,
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
// away from the messages the user is actually reading.
enter = slideInVertically(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
initialOffsetY = { it / 2 }
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
targetOffsetY = { it / 2 }
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
.zIndex(1.5f)
.windowInsetsPadding(WindowInsets.navigationBars)
.windowInsetsPadding(WindowInsets.ime)
) {
Surface(
shape = CircleShape,
color = colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, colorScheme.primary)
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
modifier = Modifier.size(22.dp),
tint = colorScheme.primary
)
}
}
}
}
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
if (showFullScreenImageViewer) {
FullScreenImageViewer(
imagePaths = viewerImagePaths,
initialIndex = initialViewerIndex,
onClose = { showFullScreenImageViewer = false }
)
}
// Dialogs and Sheets
@ -542,13 +436,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
showLocationNotesSheet = showLocationNotesSheet,
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = {
showUserSheet = false
selectedMessageForSheet = null // Reset message when dismissing
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
@ -827,10 +714,6 @@ private fun ChatDialogs(
onLocationNotesFromChannelsClick: () -> Unit,
showLocationNotesSheet: Boolean,
onLocationNotesSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?,
viewModel: ChatViewModel,
showVerificationSheet: Boolean,
onVerificationSheetDismiss: () -> Unit,
@ -884,16 +767,6 @@ private fun ChatDialogs(
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
isPresented = showUserSheet,
onDismiss = onUserSheetDismiss,
targetNickname = selectedUserForSheet,
selectedMessage = selectedMessageForSheet,
viewModel = viewModel
)
}
// MeshPeerList sheet (network view)
if (showMeshPeerListSheet){
MeshPeerListSheet(

View File

@ -18,6 +18,12 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.ui.media.FileAttachment
import com.bitchat.android.ui.media.FileViewerDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* User Action Sheet for selecting actions on a specific user (slap, hug, block)
@ -29,11 +35,21 @@ fun ChatUserSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
targetNickname: String,
selectedMessage: BitchatMessage? = null,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
conversationContext: ConversationUiContext,
modifier: Modifier = Modifier,
selectedMessage: BitchatMessage? = null,
) {
val coroutineScope = rememberCoroutineScope()
val attachment by produceState<FileAttachment?>(null, selectedMessage?.content, selectedMessage?.type) {
value = null
val message = selectedMessage
if (message != null && message.type != BitchatMessageType.Message) {
value = withContext(Dispatchers.IO) {
runCatching { FileAttachment.fromPath(message.content.trim()) }.getOrNull()
}
}
}
var showAttachmentDialog by remember(selectedMessage?.id) { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
val colorScheme = MaterialTheme.colorScheme
@ -76,7 +92,7 @@ fun ChatUserSheet(
modifier = Modifier.fillMaxWidth()
) {
// Copy message action (only show if we have a message)
selectedMessage?.let { message ->
selectedMessage?.takeIf { it.type == BitchatMessageType.Message }?.let { message ->
item {
UserActionRow(
title = stringResource(R.string.action_copy_message_title),
@ -91,18 +107,30 @@ fun ChatUserSheet(
}
}
// Only show user actions for other users' messages or when no message is selected
if (selectedMessage?.sender != viewModel.nickname.value) {
// Send private message action
attachment?.let { file ->
item {
UserActionRow(
title = stringResource(R.string.file_viewer_open_save),
subtitle = file.fileName,
titleColor = standardGrey,
onClick = { showAttachmentDialog = true },
)
}
}
// Only show user actions for other users' messages or when no message is selected
if (selectedMessage?.isFromSelf(viewModel.nickname.value, viewModel.meshServiceFacade.myPeerID) != true && selectedMessage?.sender != "system") {
// Already in this person's private conversation: do not offer opening it again.
if (conversationContext.privatePeerID == null) item {
UserActionRow(
title = stringResource(R.string.action_private_message_title, targetNickname),
subtitle = stringResource(R.string.action_private_message_subtitle),
titleColor = standardPurple,
onClick = {
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
if (conversationContext.isNostr) {
if (selectedMessage?.senderNostrPubkey != null) {
viewModel.startGeohashDM(selectedMessage.senderNostrPubkey)
} else if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
val shortId = selectedMessage.senderPeerID!!.substring(6)
viewModel.startGeohashDMByShortId(shortId)
} else {
@ -156,13 +184,14 @@ fun ChatUserSheet(
titleColor = standardRed,
onClick = {
// Check if we're in a geohash channel
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Get user's nostr public key and add to geohash block list
viewModel.blockUserInGeohash(targetNickname)
if (conversationContext.isNostr) {
viewModel.blockUserInGeohash(targetNickname, selectedMessage?.senderNostrPubkey)
} else {
// Regular mesh blocking
viewModel.sendMessage("/block $targetNickname")
val senderID = selectedMessage?.senderPeerID ?: conversationContext.privatePeerID
val meshPeerID = senderID?.let { ContactDirectory.resolve(it).meshPeerID ?: it }
if (meshPeerID != null) {
viewModel.privateChatManager.blockPeer(meshPeerID, viewModel.meshServiceFacade)
}
}
onDismiss()
}
@ -189,6 +218,15 @@ fun ChatUserSheet(
}
}
}
if (showAttachmentDialog) {
attachment?.let { file ->
FileViewerDialog(file) {
showAttachmentDialog = false
onDismiss()
}
}
}
}
@Composable

View File

@ -1671,8 +1671,8 @@ class ChatViewModel(
/**
* Block a user in geohash channels by their nickname
*/
fun blockUserInGeohash(targetNickname: String) {
geohashViewModel.blockUserInGeohash(targetNickname)
fun blockUserInGeohash(targetNickname: String, senderPubkey: String? = null) {
geohashViewModel.blockUserInGeohash(targetNickname, senderPubkey)
}
// MARK: - Navigation Management

View File

@ -0,0 +1,131 @@
package com.bitchat.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer
/** The visible conversation, independent of the timeline underneath a private sheet. */
data class ConversationUiContext(
val key: String,
val privatePeerID: String? = null,
val isNostr: Boolean = false,
) {
fun supportsMediaSend(hasMeshRoute: Boolean): Boolean =
if (privatePeerID != null) hasMeshRoute else !isNostr
}
internal fun appendConversationMention(
text: TextFieldValue,
sender: String,
context: ConversationUiContext,
): TextFieldValue {
val (name, suffix) = splitSuffix(sender)
val mention = "@$name${if (context.isNostr) suffix else ""} "
val separator = if (text.text.isEmpty() || text.text.endsWith(" ")) "" else " "
val updated = text.text + separator + mention
return TextFieldValue(updated, TextRange(updated.length))
}
/** All chat entry points get the same interaction wiring, overlays and scroll control. */
@Composable
internal fun ConversationTimeline(
messages: List<BitchatMessage>,
currentUserNickname: String,
viewModel: ChatViewModel,
context: ConversationUiContext,
onMention: (String) -> Unit,
modifier: Modifier = Modifier,
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
forceScrollToBottom: Boolean = false,
) {
ConversationTimelineContent(
messages, currentUserNickname, viewModel.meshServiceFacade, context, onMention,
onCancelTransfer = { viewModel.cancelMediaSend(it.id) },
modifier = modifier, mentionPeerIdentities = mentionPeerIdentities,
contentPadding = contentPadding, forceScrollToBottom = forceScrollToBottom,
messageActions = { message, dismiss ->
ChatUserSheet(
isPresented = true, onDismiss = dismiss,
targetNickname = splitSuffix(message.sender).first,
selectedMessage = message, viewModel = viewModel, conversationContext = context,
)
},
)
}
@Composable
internal fun ConversationTimelineContent(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.MeshService,
context: ConversationUiContext,
onMention: (String) -> Unit,
onCancelTransfer: (BitchatMessage) -> Unit,
modifier: Modifier = Modifier,
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
forceScrollToBottom: Boolean = false,
messageActions: @Composable (BitchatMessage, () -> Unit) -> Unit,
) {
var selectedMessage by remember(context.key) { mutableStateOf<BitchatMessage?>(null) }
var gallery by remember(context.key) { mutableStateOf<Pair<List<String>, Int>?>(null) }
var scrolledUp by remember(context.key) { mutableStateOf(false) }
var scrollRequest by remember(context.key) { mutableStateOf(false) }
LaunchedEffect(context.key, forceScrollToBottom) { scrollRequest = !scrollRequest }
Box(modifier) {
MessagesList(
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
conversationKey = context.key,
forceScrollToBottom = scrollRequest,
onScrolledUpChanged = { scrolledUp = it },
onNicknameClick = onMention,
onMessageLongPress = { selectedMessage = it },
onCancelTransfer = onCancelTransfer,
onImageClick = { _, paths, index ->
if (paths.isNotEmpty()) gallery = paths to index.coerceIn(paths.indices)
},
)
AnimatedVisibility(
visible = scrolledUp,
modifier = Modifier.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = contentPadding.calculateBottomPadding() + 8.dp),
) {
Surface(
shape = CircleShape,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
) {
IconButton(onClick = { scrollRequest = !scrollRequest }) {
Icon(Icons.Default.ArrowDownward, stringResource(R.string.cd_scroll_to_bottom))
}
}
}
}
gallery?.let { (paths, index) ->
FullScreenImageViewer(paths, index, onClose = { gallery = null })
}
selectedMessage?.let { message ->
messageActions(message) { selectedMessage = null }
}
}

View File

@ -275,8 +275,8 @@ class GeohashViewModel(
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
fun blockUserInGeohash(targetNickname: String) {
val pubkey = repo.findPubkeyByNickname(targetNickname)
fun blockUserInGeohash(targetNickname: String, senderPubkey: String? = null) {
val pubkey = senderPubkey ?: repo.findPubkeyByNickname(targetNickname)
if (pubkey != null) {
dataManager.addGeohashBlockedUser(pubkey)
// Refresh people list and counts to remove blocked entry immediately

View File

@ -1816,34 +1816,28 @@ fun PrivateChatSheet(
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
// Messages list
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
MessagesList(
val conversationContext = ConversationUiContext(
key = "dm:$conversationID",
privatePeerID = conversationID,
isNostr = isNostrPeer,
)
var forceScrollToBottom by remember(conversationID) { mutableStateOf(false) }
var messageText by remember(conversationID) {
mutableStateOf(androidx.compose.ui.text.input.TextFieldValue(viewModel.conversationDraft(peerID)))
}
ConversationTimeline(
messages = messages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
viewModel = viewModel,
context = conversationContext,
modifier = Modifier.weight(1f),
conversationKey = "dm:$peerID",
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { /* handle mention */ },
onMessageLongPress = { /* handle long press */ },
onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) },
onImageClick = { _, _, _ -> /* handle image click */ }
onMention = { sender ->
messageText = appendConversationMention(messageText, sender, conversationContext)
viewModel.setConversationDraft(peerID, messageText.text)
},
)
// Input section. No divider here: ChatInputSection draws its own fade and
// hairline.
var messageText by remember(peerID) {
mutableStateOf(
androidx.compose.ui.text.input.TextFieldValue(
viewModel.conversationDraft(peerID)
)
)
}
ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText ->
@ -1885,7 +1879,7 @@ fun PrivateChatSheet(
currentChannel = null,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = true
showMediaButtons = conversationContext.supportsMediaSend(isConnected)
)
}

View File

@ -6,8 +6,6 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Animatable
@ -40,12 +38,10 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -87,8 +83,6 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.ChatUiModeManager
import com.bitchat.android.ui.theme.ChatVisualTokens
@ -424,7 +418,7 @@ fun MessageItem(
) {
// Provide a small end padding for own private messages so overlay doesn't cover text.
// Bubble mode draws the status beneath the bubble instead, so no inset is needed.
val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
val endPad = if (!bubbles && message.isPrivate && message.isFromSelf(currentUserNickname, meshService.myPeerID)) 16.dp else 0.dp
// Create a custom layout that combines selectable text with clickable nickname areas
MessageTextWithClickableNicknames(
message = message,
@ -448,7 +442,7 @@ fun MessageItem(
// Delivery status for private messages (overlay, non-displacing). Bubble mode aligns
// own messages to the end edge where this overlay lives, so it renders below instead.
if (!bubbles && message.isPrivate && message.sender == currentUserNickname) {
if (!bubbles && message.isPrivate && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
@ -461,23 +455,6 @@ fun MessageItem(
}
}
// Bubble mode: text and media bubbles carry the marker inline, trailing the timestamp.
// File rows have no bubble shell, so their marker stays beneath the end-aligned row.
if (bubbles && message.type == BitchatMessageType.File &&
message.isPrivate && message.sender == currentUserNickname
) {
message.deliveryStatus?.let { status ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, end = 4.dp),
contentAlignment = Alignment.CenterEnd
) {
DeliveryStatusIcon(status = status)
}
}
}
// Link previews removed; links are now highlighted inline and clickable within the message text
}
}
@ -508,8 +485,7 @@ fun MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
myPeerID = meshService.myPeerID,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
@ -527,8 +503,7 @@ fun MessageItem(
com.bitchat.android.ui.media.AudioMessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
myPeerID = meshService.myPeerID,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
@ -540,129 +515,11 @@ fun MessageItem(
return
}
// File special rendering
if (message.type == BitchatMessageType.File) {
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, _) = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
if (st.total > 0 && st.reached < st.total) {
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
} else null to null
}
else -> null to null
}
Column(
modifier = modifier.fillMaxWidth(),
// Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles.
horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
Alignment.End
} else {
Alignment.Start
},
) {
// Header: nickname + timestamp line above the file, identical styling to text messages
val headerText = formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
// Try to load the file packet from the path
val packet = try {
val file = java.io.File(path)
if (file.exists()) {
// Create a temporary BitchatFilePacket for display
// In a real implementation, this would be stored with the packet metadata
com.bitchat.android.model.BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name),
content = file.readBytes()
)
} else null
} catch (e: Exception) {
null
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
Arrangement.End
} else {
Arrangement.Start
}
) {
Box {
if (packet != null) {
if (overrideProgress != null) {
// Show sending animation while in-flight
com.bitchat.android.ui.media.FileSendingAnimation(
fileName = packet.fileName,
progress = overrideProgress,
modifier = Modifier.fillMaxWidth()
)
} else {
// Static file display with open/save dialog
FileMessageItem(
packet = packet,
onFileClick = {
// handled inside FileMessageItem via dialog
}
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.cd_cancel),
tint = colorScheme.onSurface,
modifier = Modifier.size(14.dp)
)
}
}
} else {
Text(
text = stringResource(R.string.file_unavailable),
fontFamily = BitchatFontFamily,
color = palette.textTertiary
)
}
}
}
}
com.bitchat.android.ui.media.FileAttachmentMessage(
message, currentUserNickname, meshService.myPeerID, timeFormatter,
showSender, bubbles, onNicknameClick, onMessageLongPress, onCancelTransfer, modifier,
)
return
}
@ -1026,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

@ -1,166 +1,57 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.isFromSelf
import java.text.SimpleDateFormat
@Composable
fun AudioMessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
myPeerID: String,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
bubbles: Boolean = false
bubbles: Boolean = false,
) {
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val liveMessageIDs by com.bitchat.android.features.voice.LiveVoiceManager
.getInstance(context).liveMessageIDs.collectAsState()
val liveMessageIDs by LiveVoiceManager.getInstance(LocalContext.current)
.liveMessageIDs.collectAsState()
val isLive = message.id in liveMessageIDs
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
if (st.total > 0 && st.reached < st.total) {
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
} else null to null
}
else -> null to null
}
// Bubble mode wraps the voice note in the same tinted shell as text bubbles; Matrix mode
// keeps the flat header-plus-player layout.
if (bubbles) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor,
modifier = Modifier.widthIn(max = 260.dp)
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(26.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
val progress = mediaTransferProgress(message, isSelf)
MediaMessageLayout(
message, currentUserNickname, myPeerID, timeFormatter,
showSender, bubbles, onNicknameClick, onMessageLongPress, modifier,
) {
// Header: nickname + timestamp line above the audio note, identical styling to text messages
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = if (bubbles) Modifier.widthIn(max = 300.dp) else Modifier) {
if (isLive) {
androidx.compose.material3.Text(
text = "LIVE",
color = Color(0xFFFFB300),
style = androidx.compose.material3.MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(end = 8.dp)
Text(
stringResource(R.string.voice_waveform_live),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor,
path = message.content.trim(),
isLive = isLive,
progressOverride = progress,
onCancelTransfer = onCancelTransfer?.let { cancel -> { cancel(message) } },
)
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(26.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
}

View File

@ -0,0 +1,77 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.isFromSelf
import java.io.File
import java.io.OutputStream
import java.text.SimpleDateFormat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Display metadata; file contents are read only when an explicit open/save action needs them. */
data class FileAttachment(val path: String, val fileName: String, val fileSize: Long, val mimeType: String) {
companion object {
fun fromPath(path: String): FileAttachment? {
val file = File(path)
return if (file.isFile) FileAttachment(
path, file.name, file.length(), FileUtils.getMimeTypeFromExtension(file.name),
) else null
}
}
fun copyTo(output: OutputStream) {
File(path).inputStream().use { it.copyTo(output) }
}
}
@Composable
internal fun FileAttachmentMessage(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
bubbles: Boolean,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
) {
val path = message.content.trim()
val attachment by produceState<FileAttachment?>(null, path) {
value = withContext(Dispatchers.IO) { runCatching { FileAttachment.fromPath(path) }.getOrNull() }
}
val progress = mediaTransferProgress(message, message.isFromSelf(currentUserNickname, myPeerID))
MediaMessageLayout(
message, currentUserNickname, myPeerID, timeFormatter,
showSender, bubbles, onNicknameClick, onMessageLongPress, modifier,
) {
Box {
val file = attachment
when {
file == null -> Text(stringResource(R.string.file_unavailable), color = MaterialTheme.colorScheme.onSurfaceVariant)
progress != null -> FileSendingAnimation(fileName = file.fileName, progress = progress)
else -> FileMessageItem(
attachment = file,
onLongPress = onMessageLongPress?.let { action -> { action(message) } },
)
}
if (progress != null && onCancelTransfer != null) {
Box(Modifier.align(Alignment.TopEnd)) {
CancelMediaTransferButton { onCancelTransfer(message) }
}
}
}
}
}

View File

@ -2,24 +2,20 @@ package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -33,23 +29,23 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
/**
* Modern chat-style file message display
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FileMessageItem(
packet: BitchatFilePacket,
onFileClick: () -> Unit,
modifier: Modifier = Modifier
attachment: FileAttachment,
modifier: Modifier = Modifier,
onLongPress: (() -> Unit)? = null
) {
var showDialog by remember { mutableStateOf(false) }
var showDialog by remember(attachment.path) { mutableStateOf(false) }
Card(
modifier = modifier
.fillMaxWidth(0.8f)
.clickable { showDialog = true },
.combinedClickable(onClick = { showDialog = true }, onLongClick = onLongPress),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
@ -64,7 +60,7 @@ fun FileMessageItem(
Icon(
imageVector = Icons.Filled.Description,
contentDescription = stringResource(com.bitchat.android.R.string.cd_file),
tint = getFileIconColor(packet.fileName),
tint = getFileIconColor(attachment.fileName),
modifier = Modifier.size(32.dp)
)
@ -74,7 +70,7 @@ fun FileMessageItem(
) {
// File name
Text(
text = packet.fileName,
text = attachment.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
@ -87,13 +83,13 @@ fun FileMessageItem(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = FileUtils.formatFileSize(packet.fileSize),
text = FileUtils.formatFileSize(attachment.fileSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// File type indicator
FileTypeBadge(mimeType = packet.mimeType)
FileTypeBadge(mimeType = attachment.mimeType)
}
}
}
@ -102,13 +98,8 @@ fun FileMessageItem(
// File viewer dialog
if (showDialog) {
FileViewerDialog(
packet = packet,
attachment = attachment,
onDismiss = { showDialog = false },
onSaveToDevice = { content, fileName ->
// In a real implementation, this would save to Downloads
// For now, just log that file was "saved"
android.util.Log.d("FileSharing", "Would save file: $fileName")
}
)
}
}

View File

@ -1,163 +1,94 @@
package com.bitchat.android.ui.media
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
import kotlinx.coroutines.launch
import java.io.File
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Dialog for handling received file messages in modern chat style
*/
@Composable
fun FileViewerDialog(
packet: BitchatFilePacket,
onDismiss: () -> Unit,
onSaveToDevice: (ByteArray, String) -> Unit
) {
fun FileViewerDialog(attachment: FileAttachment, onDismiss: () -> Unit) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Dialog(onDismissRequest = onDismiss) {
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// File received header
Text(
text = stringResource(R.string.file_viewer_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
// File info
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.file_viewer_name, packet.fileName),
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
Text(
text = stringResource(R.string.file_viewer_size, FileUtils.formatFileSize(packet.fileSize)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.file_viewer_type, packet.mimeType),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Open/Save button
Button(
onClick = {
coroutineScope.launch {
// Try to save to Downloads first
try {
onSaveToDevice(packet.content, packet.fileName)
onDismiss()
} catch (e: Exception) {
// If save fails, try to open directly
tryOpenFile(context, packet)
onDismiss()
}
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(stringResource(R.string.file_viewer_open_save))
}
// Dismiss button
Button(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text(stringResource(R.string.close_with_emoji))
val scope = rememberCoroutineScope()
var failed by remember(attachment.path) { mutableStateOf(false) }
var busy by remember(attachment.path) { mutableStateOf(false) }
val save = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(attachment.mimeType),
) { uri ->
if (uri != null) {
scope.launch {
busy = true
failed = false
try {
withContext(Dispatchers.IO) {
requireNotNull(context.contentResolver.openOutputStream(uri)).use(attachment::copyTo)
}
onDismiss()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
failed = true
} finally {
busy = false
}
}
}
}
}
/**
* Attempts to open a file using system viewers or save to device
*/
private fun tryOpenFile(context: Context, packet: BitchatFilePacket) {
try {
// First try to save to temp file and open
val tempFile = File.createTempFile("bitchat_", ".${packet.fileName.substringAfterLast(".")}", context.cacheDir)
tempFile.writeBytes(packet.content)
tempFile.deleteOnExit()
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, packet.mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
// No app can handle this file type - just show a message
// In a real app, you'd show a toast or snackbar
}
} catch (e: Exception) {
// Handle any errors gracefully
}
AlertDialog(
onDismissRequest = { if (!busy) onDismiss() },
title = { Text(attachment.fileName) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.file_viewer_size, FileUtils.formatFileSize(attachment.fileSize)))
Text(stringResource(R.string.file_viewer_type, attachment.mimeType))
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
if (failed) Text(stringResource(R.string.attachment_failed), color = MaterialTheme.colorScheme.error)
}
},
confirmButton = {
TextButton(enabled = !busy, onClick = {
failed = false
try { save.launch(attachment.fileName) } catch (_: Exception) { failed = true }
}) { Text(stringResource(R.string.attachment_save)) }
},
dismissButton = {
Row {
TextButton(enabled = !busy, onClick = {
scope.launch {
busy = true
failed = false
try {
val uri = withContext(Dispatchers.IO) {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", File(attachment.path))
}
context.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, attachment.mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
onDismiss()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
failed = true
} finally { busy = false }
}
}) { Text(stringResource(R.string.attachment_open)) }
TextButton(enabled = !busy, onClick = onDismiss) { Text(stringResource(R.string.cancel_lower)) }
}
},
)
}

View File

@ -1,15 +1,9 @@
package com.bitchat.android.ui.media
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -18,27 +12,24 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.bitchat.android.ui.isFromSelf
@Composable
fun ImageMessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
myPeerID: String,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
@ -48,99 +39,35 @@ fun ImageMessageItem(
showSender: Boolean = true,
bubbles: Boolean = false
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
// Bubble mode wraps the image in the same tinted shell as text bubbles; Matrix mode keeps
// the flat header-plus-card layout.
val bubblesMode = bubbles
val imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
val context = LocalContext.current
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
// Collect all image paths from messages for swipe navigation
val imagePaths = remember(messages) {
val bmp by produceState<android.graphics.Bitmap?>(null, path) {
value = withContext(Dispatchers.IO) {
runCatching { android.graphics.BitmapFactory.decodeFile(path) }.getOrNull()
}
}
val imagePaths = remember(messages, path) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
.map { it.content.trim() }.ifEmpty { listOf(path) }
}
val haptic = LocalHapticFeedback.current
if (bubblesMode) {
MediaBubbleShell(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
showSender = showSender,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onLongPress = { onMessageLongPress?.invoke(message) },
modifier = modifier,
) {
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
},
)
}
return
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
MediaMessageLayout(
message, currentUserNickname, myPeerID, timeFormatter,
showSender, bubbles, onNicknameClick, onMessageLongPress, modifier,
) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
myPeerID = meshService.myPeerID,
palette = palette,
contentColor = colorScheme.onSurface,
timeFormatter = timeFormatter,
includeSender = showSender
isSelf = message.isFromSelf(currentUserNickname, myPeerID),
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp),
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = onMessageLongPress?.let { action -> {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
action(message)
} },
)
AnnotatedClickableText(
text = headerText,
annotationTags = listOf("nickname_click"),
onAnnotationClick = { tag, item ->
if (tag == "nickname_click" && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(item)
true
} else {
false
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
if (bmp != null) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
ImageMessageCard(
message = message,
currentUserNickname = currentUserNickname,
path = path,
bmp = bmp,
imagePaths = imagePaths,
imageShape = imageShape,
onImageClick = onImageClick,
onCancelTransfer = onCancelTransfer,
onLongPress = null,
)
}
} else {
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
}
}
}
@ -148,7 +75,7 @@ fun ImageMessageItem(
@Composable
private fun ImageMessageCard(
message: BitchatMessage,
currentUserNickname: String,
isSelf: Boolean,
path: String,
bmp: android.graphics.Bitmap?,
imagePaths: List<String>,
@ -163,10 +90,7 @@ private fun ImageMessageCard(
}
val img = bmp.asImageBitmap()
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
val progressFraction: Float? = when (val st = message.deliveryStatus) {
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
else -> null
}
val progressFraction = mediaTransferProgress(message, isSelf)
Box {
val imageModifier = Modifier
.widthIn(max = 300.dp)
@ -179,7 +103,7 @@ private fun ImageMessageCard(
},
onLongClick = { onLongPress?.invoke() },
)
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
if (progressFraction != null && progressFraction < 1f && isSelf) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
@ -197,19 +121,9 @@ private fun ImageMessageCard(
contentScale = ContentScale.Fit
)
}
// Cancel button overlay during sending
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
if (showCancel) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
if (progressFraction != null && onCancelTransfer != null) {
Box(Modifier.align(Alignment.TopEnd)) {
CancelMediaTransferButton { onCancelTransfer(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

@ -0,0 +1,86 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.formatMessageHeaderAnnotatedString
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
internal fun mediaTransferProgress(message: BitchatMessage, isSelf: Boolean): Float? {
val status = message.deliveryStatus as? DeliveryStatus.PartiallyDelivered ?: return null
if (!isSelf || status.total <= 0 || status.reached >= status.total) return null
return (status.reached.toFloat() / status.total).coerceIn(0f, 1f)
}
/** One header, action surface and metadata policy for every attachment and conversation. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun MediaMessageLayout(
message: BitchatMessage,
currentUserNickname: String,
myPeerID: String,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
bubbles: Boolean,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val onLongPress = onMessageLongPress?.let { action -> { action(message) } }
if (bubbles) {
MediaBubbleShell(
message, currentUserNickname, myPeerID, showSender, timeFormatter,
onNicknameClick, onLongPress, modifier, content,
)
} else {
val haptic = LocalHapticFeedback.current
Column(modifier.fillMaxWidth().combinedClickable(
enabled = onLongPress != null,
onClick = {},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress?.invoke()
},
)) {
AnnotatedClickableText(
text = formatMessageHeaderAnnotatedString(
message, currentUserNickname, myPeerID, LocalBitchatPalette.current,
MaterialTheme.colorScheme.onSurface, timeFormatter, includeSender = showSender,
),
annotationTags = listOf("nickname_click"),
onAnnotationClick = { _, nickname ->
onNicknameClick?.let { it(nickname); true } ?: false
},
onLongPress = onLongPress,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
)
content()
}
}
}
@Composable
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

@ -5,38 +5,40 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import android.media.MediaPlayer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
@Composable
fun VoiceNotePlayer(
path: String,
modifier: Modifier = Modifier,
progressOverride: Float? = null,
progressColor: Color? = null
progressColor: Color? = null,
isLive: Boolean = false,
onCancelTransfer: (() -> Unit)? = null,
) {
var isPlaying by remember { mutableStateOf(false) }
var isPrepared by remember { mutableStateOf(false) }
var isError by remember { mutableStateOf(false) }
var progress by remember { mutableStateOf(0f) }
var durationMs by remember { mutableStateOf(0) }
var progress by remember { mutableFloatStateOf(0f) }
var durationMs by remember { mutableIntStateOf(0) }
val player = remember { MediaPlayer() }
// Seek function - position is a fraction from 0.0 to 1.0
val seekTo: (Float) -> Unit = { position ->
if (isPrepared && durationMs > 0) {
if (isPrepared && !isError && !isLive && progressOverride == null && durationMs > 0) {
val seekMs = (position * durationMs).toInt().coerceIn(0, durationMs)
try {
player.seekTo(seekMs)
@ -45,7 +47,7 @@ fun VoiceNotePlayer(
}
}
LaunchedEffect(path) {
LaunchedEffect(path, isLive) {
isPrepared = false
isError = false
progress = 0f
@ -66,6 +68,7 @@ fun VoiceNotePlayer(
isPlaying = false
true
}
if (isLive) return@LaunchedEffect
player.setDataSource(path)
player.prepareAsync()
} catch (_: Exception) {
@ -92,14 +95,15 @@ fun VoiceNotePlayer(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Disable play/pause while showing send progress override (optional UX choice)
val controlsEnabled = isPrepared && !isError && progressOverride == null
FilledTonalIconButton(onClick = { if (controlsEnabled) isPlaying = !isPlaying }, enabled = controlsEnabled, modifier = Modifier.size(28.dp)) {
val controlsEnabled = isPrepared && !isError && !isLive && progressOverride == null
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 = if (isPlaying) "Pause" else "Play"
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)
@ -108,9 +112,12 @@ fun VoiceNotePlayer(
path = path,
sendProgress = progressOverride,
playbackProgress = if (progressOverride == null) progress else null,
onSeek = seekTo
onSeek = if (controlsEnabled) seekTo else null,
isLive = isLive,
progressColor = progressColor
)
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
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 "--:--"
Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}

View File

@ -0,0 +1,28 @@
package com.bitchat.android.ui.media
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.android.features.voice.resampleWave
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
/** Awaiting the callback makes cancellation discard results from a previous media source. */
internal suspend fun loadVoiceWaveform(
path: String,
cached: (String) -> FloatArray? = VoiceWaveformCache::get,
extract: (String, (FloatArray?) -> Unit) -> Unit = { source, complete ->
AudioWaveformExtractor.extractAsync(source, sampleCount = 120) { samples ->
samples?.let { VoiceWaveformCache.put(source, it) }
complete(samples)
}
},
): List<Float>? {
val samples = cached(path) ?: suspendCancellableCoroutine<FloatArray?> { continuation ->
extract(path) { result ->
if (continuation.isActive) continuation.resume(result)
}
}
return samples?.takeIf { it.isNotEmpty() }?.let {
(if (it.size == 120) it else resampleWave(it, 120)).toList()
}
}

View File

@ -5,8 +5,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.produceState
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.res.stringResource
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
@ -18,9 +25,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.android.features.voice.resampleWave
@Composable
fun ScrollingWaveformRecorder(
@ -51,35 +55,52 @@ fun WaveformPreview(
sendProgress: Float?,
playbackProgress: Float?,
onLoaded: ((FloatArray) -> Unit)? = null,
onSeek: ((Float) -> Unit)? = null
onSeek: ((Float) -> Unit)? = null,
isLive: Boolean = false,
progressColor: Color? = null,
loadSamples: suspend (String) -> List<Float>? = { loadVoiceWaveform(it) },
) {
val cached = remember(path) { VoiceWaveformCache.get(path) }
val stateSamples = remember { mutableStateListOf<Float>() }
val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f
LaunchedEffect(cached) {
if (cached != null) {
val normalized = if (cached.size != 120) resampleWave(cached, 120) else cached
stateSamples.clear(); stateSamples.addAll(normalized.toList())
} else {
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
if (arr != null) {
VoiceWaveformCache.put(path, arr)
stateSamples.clear(); stateSamples.addAll(arr.toList())
onLoaded?.invoke(arr)
}
}
val latestOnLoaded by rememberUpdatedState(onLoaded)
// The live file is still being written. Decode only once it has been finalized.
// Both keys matter: finalization may replace the path or finish the same file.
val samples by produceState<List<Float>?>(null, path, isLive) {
value = null
if (!isLive) {
value = loadSamples(path).also { loaded ->
loaded?.let { latestOnLoaded?.invoke(it.toFloatArray()) }
} ?: emptyList()
}
}
val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f
val description = stringResource(when {
isLive -> R.string.voice_waveform_live
samples == null -> R.string.voice_waveform_loading
samples!!.isEmpty() -> R.string.voice_waveform_unavailable
else -> R.string.voice_waveform_ready
})
val stateSamples = samples.orEmpty()
if (!isLive && samples == null) {
LinearProgressIndicator(modifier = modifier.semantics { stateDescription = description })
return
}
if (!isLive && stateSamples.isEmpty()) {
Text(
text = description,
modifier = modifier.semantics { stateDescription = description },
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
maxLines = 1,
)
return
}
WaveformCanvas(
modifier = modifier,
samples = stateSamples,
modifier = modifier.semantics { stateDescription = description },
samples = stateSamples.ifEmpty { List(40) { 0.08f } },
fillProgress = if (stateSamples.isEmpty()) 0f else progress,
baseColor = Color(0x2200FF7F),
fillColor = when {
sendProgress != null -> Color(0xFF1E88E5) // blue while sending
else -> Color(0xFF00C851) // green during playback
},
onSeek = onSeek
fillColor = progressColor ?: if (sendProgress != null) Color(0xFF1E88E5) else Color(0xFF00C851),
onSeek = if (isLive) null else onSeek
)
}
@ -113,7 +134,6 @@ private fun WaveformCanvas(
if (n <= 0) return@Canvas
val stepX = w / n
val midY = h / 2f
val radius = 2.dp.toPx()
val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)
val filledUntil = (n * fillProgress).toInt()
for (i in 0 until n) {
@ -123,7 +143,7 @@ private fun WaveformCanvas(
val yTop = midY - lineH / 2f
val yBot = midY + lineH / 2f
drawLine(
color = if (i <= filledUntil) fillColor else baseColor,
color = if (i < filledUntil) fillColor else baseColor,
start = Offset(x, yTop),
end = Offset(x, yBot),
strokeWidth = stroke.width,

View File

@ -654,4 +654,13 @@
<string name="about_app_language">App language</string>
<string name="about_system_default">System default</string>
<string name="about_select_language">Select language</string>
<!-- New media states use the English fallback until localized translations are available. -->
<string name="voice_waveform_live" tools:ignore="MissingTranslation">Live audio</string>
<string name="voice_waveform_loading" tools:ignore="MissingTranslation">Loading waveform</string>
<string name="voice_waveform_unavailable" tools:ignore="MissingTranslation">Waveform unavailable</string>
<string name="voice_waveform_ready" tools:ignore="MissingTranslation">Audio waveform</string>
<string name="voice_unavailable" tools:ignore="MissingTranslation">Unavailable</string>
<string name="attachment_open" tools:ignore="MissingTranslation">Open</string>
<string name="attachment_save" tools:ignore="MissingTranslation">Save</string>
<string name="attachment_failed" tools:ignore="MissingTranslation">Could not open or save this file. Please try again.</string>
</resources>

View File

@ -0,0 +1,41 @@
package com.bitchat.android.ui
import androidx.compose.ui.text.input.TextFieldValue
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.mediaTransferProgress
import java.util.Date
import org.junit.Assert.*
import org.junit.Test
class ConversationUiParityTest {
@Test fun `private media follows mesh route availability regardless of public timeline`() {
for (nostr in listOf(false, true)) {
val context = ConversationUiContext("private", "synthetic-peer", nostr)
assertFalse(context.supportsMediaSend(false))
assertTrue(context.supportsMediaSend(true))
}
assertTrue(ConversationUiContext("mesh").supportsMediaSend(false))
assertFalse(ConversationUiContext("nostr", isNostr = true).supportsMediaSend(true))
}
@Test fun `mentions use the visible conversation and keep the cursor at the end`() {
val mesh = appendConversationMention(TextFieldValue("hello"), "alice#1234", ConversationUiContext("mesh"))
assertEquals("hello @alice ", mesh.text)
assertEquals(mesh.text.length, mesh.selection.start)
val nostr = appendConversationMention(TextFieldValue("hello "), "alice#1234", ConversationUiContext("dm", "peer", true))
assertEquals("hello @alice#1234 ", nostr.text)
}
@Test fun `renamed sender retains transfer controls through peer identity`() {
val message = BitchatMessage(
sender = "old-name", senderPeerID = "synthetic-self", content = "fixture",
timestamp = Date(0), isPrivate = true,
deliveryStatus = DeliveryStatus.PartiallyDelivered(1, 4),
)
assertEquals(0.25f, mediaTransferProgress(message, message.isFromSelf("new-name", "synthetic-self")))
assertNull(mediaTransferProgress(message, false))
assertNull(mediaTransferProgress(message.copy(deliveryStatus = DeliveryStatus.PartiallyDelivered(4, 4)), true))
assertNull(mediaTransferProgress(message.copy(deliveryStatus = DeliveryStatus.PartiallyDelivered(0, 0)), true))
}
}

View File

@ -0,0 +1,34 @@
package com.bitchat.android.ui.media
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class FileAttachmentTest {
@get:Rule val temporary = TemporaryFolder()
@Test fun `export copies the actual bytes to the selected destination`() {
val file = temporary.newFile("synthetic.txt").apply { writeText("Synthetic attachment") }
val attachment = FileAttachment.fromPath(file.path)!!
val output = ByteArrayOutputStream()
attachment.copyTo(output)
assertArrayEquals(file.readBytes(), output.toByteArray())
assertEquals(file.length(), attachment.fileSize)
}
@Test(expected = IOException::class)
fun `failed export propagates failure rather than reporting success`() {
val file = temporary.newFile("synthetic.txt").apply { writeText("Synthetic attachment") }
FileAttachment.fromPath(file.path)!!.copyTo(object : OutputStream() {
override fun write(value: Int) { throw IOException("Synthetic failure") }
})
}
@Test fun `missing file does not produce an attachment`() {
assertNull(FileAttachment.fromPath(temporary.root.resolve("missing.txt").path))
}
}

View File

@ -0,0 +1,42 @@
package com.bitchat.android.ui.media
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class VoiceWaveformLoaderTest {
@Test fun `cold source loads without a cached waveform`() = runTest {
val result = loadVoiceWaveform("synthetic-final", cached = { null }, extract = { path, callback ->
assertEquals("synthetic-final", path)
callback(floatArrayOf(0f, 1f))
})
assertEquals(120, result!!.size)
assertEquals(0f, result.first())
assertEquals(1f, result.last())
}
@Test fun `warm source avoids decoding`() = runTest {
val result = loadVoiceWaveform("cached", cached = { FloatArray(120) { 0.5f } }, extract = { _, _ -> fail("Decoded cached media") })
assertEquals(List(120) { 0.5f }, result)
}
@Test fun `late completion from a cancelled live source cannot replace the finished source`() = runTest {
var oldCallback: ((FloatArray?) -> Unit)? = null
val old = async { loadVoiceWaveform("live", cached = { null }, extract = { _, callback -> oldCallback = callback }) }
runCurrent()
old.cancel()
val finished = loadVoiceWaveform("finished", cached = { null }, extract = { _, callback -> callback(FloatArray(120) { 1f }) })
oldCallback!!(FloatArray(120) { 0f })
runCurrent()
assertTrue(old.isCancelled)
assertEquals(List(120) { 1f }, finished)
}
@Test fun `decode failures and empty samples are unavailable`() = runTest {
assertNull(loadVoiceWaveform("bad", cached = { null }, extract = { _, callback -> callback(null) }))
assertNull(loadVoiceWaveform("empty", cached = { floatArrayOf() }))
}
}

View File

@ -70,6 +70,7 @@ mlkit-barcode = "17.3.0"
# Testing
junit = "4.13.2"
androidx-test-ext = "1.3.0"
androidx-test-runner = "1.7.0"
espresso = "3.7.0"
mockito-kotlin = "6.3.0"
mockito-core = "5.23.0"
@ -152,6 +153,7 @@ mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version
# Testing
junit = { module = "junit:junit", version.ref = "junit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }

View File

@ -4771,6 +4771,11 @@
<sha256 value="f65860bce58a2bfadf05fdc516d38333421f0e387fef87e24449c0394c80d254" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.6.4">
<artifact name="kotlinx-coroutines-bom-1.6.4.pom">
<sha256 value="ab2614855fba66aa8a42514dbe3d5a884315ffe1ed63f5932e710a8006245ce1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.7.3">
<artifact name="kotlinx-coroutines-bom-1.7.3.pom">
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
@ -4781,6 +4786,11 @@
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1">
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.9.0">
<artifact name="kotlinx-coroutines-bom-1.9.0.pom">
<sha256 value="bea5511e9001f2c593ab5080df131b219ab5e2085cc5979ce583eacd4946fd78" origin="Generated by Gradle"/>