diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef4cbe53..271bb9ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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). diff --git a/app/gradle.lockfile b/app/gradle.lockfile index c66fac1d..bb1dc60c 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -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 diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt new file mode 100644 index 00000000..eb654258 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatMediaRenderingInstrumentedTest.kt @@ -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() +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatUiParityInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatUiParityInstrumentedTest.kt new file mode 100644 index 00000000..419a3856 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatUiParityInstrumentedTest.kt @@ -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?>() + val loaded = mutableListOf() + 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) } + } +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatUiTestRunner.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatUiTestRunner.kt new file mode 100644 index 00000000..72dd7d49 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatUiTestRunner.kt @@ -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) +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt new file mode 100644 index 00000000..af132278 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ChatVisualStabilityInstrumentedTest.kt @@ -0,0 +1,145 @@ +package com.bitchat.android.ui + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.bitchat.android.features.voice.VoiceWaveformCache +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.ui.media.VoiceNotePlayer +import com.bitchat.android.ui.theme.BitchatTheme +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class ChatVisualStabilityInstrumentedTest { + @get:Rule val compose = createComposeRule() + + @Test fun deliveryChangesKeepMessageTimestampAndMediaBoundsStable() { + val app = InstrumentationRegistry.getInstrumentation().targetContext + val audio = File(app.cacheDir, "synthetic-stability.wav").apply { writeBytes(syntheticWave()) } + val image = File(app.cacheDir, "synthetic-stability.png") + Bitmap.createBitmap(80, 40, Bitmap.Config.ARGB_8888).also { bitmap -> + image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + } + val file = File(app.cacheDir, "synthetic-stability.txt").apply { writeText("Synthetic attachment") } + val contents = mapOf(BitchatMessageType.Message to "Synthetic note.", BitchatMessageType.Audio to audio.path, + BitchatMessageType.Image to image.path, BitchatMessageType.File to file.path) + var textBody by mutableStateOf("Synthetic note.") + var cancelled = 0 + var type by mutableStateOf(BitchatMessageType.Message) + var status by mutableStateOf(null) + var bubbles by mutableStateOf(true) + var width by mutableStateOf(320) + var self by mutableStateOf(true) + val timestamp = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()).format(Date(0)) + compose.setContent { + BitchatTheme(darkTheme = true) { + Surface(Modifier.width(width.dp).fillMaxHeight().testTag("capture")) { + Column(Modifier.padding(16.dp)) { + MessageItem( + BitchatMessage(id = "synthetic-stability", sender = if (self) "me" else "alice", + senderPeerID = if (self) "synthetic-self" else "synthetic-other", content = if (type == BitchatMessageType.Message) textBody else contents.getValue(type), + type = type, timestamp = Date(0), isPrivate = true, deliveryStatus = status), + "me", syntheticMesh(), bubbles = bubbles, onCancelTransfer = { cancelled++ }, modifier = Modifier.testTag("row"), + ) + Text("Next message", Modifier.testTag("next")) + } + } + } + } + val statuses = listOf(null, DeliveryStatus.Sending, DeliveryStatus.PartiallyDelivered(1, 3), DeliveryStatus.Sent, + DeliveryStatus.Delivered("synthetic-peer", Date(0)), DeliveryStatus.Read("synthetic-peer", Date(0)), DeliveryStatus.Failed("synthetic")) + val differences = mutableListOf() + val evidence = File(app.filesDir, "chat-ui-stability").apply { mkdirs() } + val record = InstrumentationRegistry.getArguments().getString("recordRegression") == "true" + val cases = contents.keys.map { it to "Synthetic note." } + listOf( + BitchatMessageType.Message to "Synthetic text near a line boundary", + BitchatMessageType.Message to "A longer synthetic message that wraps across several lines to exercise timestamp placement.", + ) + for (logicalWidth in listOf(320, 411)) for (bubble in listOf(true, false)) for (own in listOf(true, false)) for ((kind, body) in cases) { + compose.runOnIdle { width = logicalWidth; bubbles = bubble; self = own; type = kind; textBody = body; status = null } + if (kind == BitchatMessageType.Audio) compose.waitUntil(10_000) { compose.onAllNodes(hasStateDescription("Audio waveform")).fetchSemanticsNodes().size == 1 } + if (kind == BitchatMessageType.Image) compose.waitUntil(10_000) { compose.onAllNodesWithContentDescription("Image").fetchSemanticsNodes().size == 1 } + if (kind == BitchatMessageType.File) compose.waitUntil(10_000) { compose.onAllNodesWithText(file.name).fetchSemanticsNodes().size == 1 } + fun bounds(): Map { + val result = mutableMapOf("row" to compose.onNodeWithTag("row").fetchSemanticsNode().boundsInRoot.toString(), + "next" to compose.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.toString(), + "timestamp" to compose.onNode(hasText(timestamp, substring = true), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString()) + val media = when (kind) { + BitchatMessageType.Audio -> hasStateDescription("Audio waveform") + BitchatMessageType.Image -> hasContentDescription("Image") + BitchatMessageType.File -> hasText(file.name) + else -> hasText(body, substring = true) + } + result["content"] = compose.onNode(media, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString() + return result + } + val initial = bounds() + for ((index, nextStatus) in statuses.withIndex()) { + // Image/file transfer animations intentionally replace their content; receipt ticks do not. + if (nextStatus is DeliveryStatus.PartiallyDelivered && kind != BitchatMessageType.Audio) continue + compose.runOnIdle { status = nextStatus } + compose.waitForIdle() + if (own && kind == BitchatMessageType.Audio && nextStatus is DeliveryStatus.PartiallyDelivered) { + val previous = cancelled + compose.onNodeWithContentDescription("Cancel").performClick() + compose.runOnIdle { assertTrue("Cancel callback must remain wired", cancelled == previous + 1) } + } + val current = bounds() + val label = "$logicalWidth-${if (bubble) "bubbles" else "matrix"}-${if (own) "sent" else "received"}-$kind-$index" + initial.forEach { (node, rect) -> if (current[node] != rect) differences += "$label bodyLength=${body.length} $node: $rect -> ${current[node]}" } + if (logicalWidth == 320 && own && kind == BitchatMessageType.Audio && index in listOf(0, 2, 3, 5)) { + File(evidence, "${if (record) "before" else "after"}-$label.png").outputStream().use { + compose.onNodeWithTag("capture").captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + } + } + File(evidence, "${if (record) "before" else "after"}-bounds.txt").writeText(differences.joinToString("\n")) + assertTrue("Delivery status displaced content:\n${differences.take(20).joinToString("\n")}", record || differences.isEmpty()) + } + + @Test fun playerKeepsGreenPlaybackAndBlueTransferColors() { + val app = InstrumentationRegistry.getInstrumentation().targetContext + val audio = File(app.cacheDir, "synthetic-color.wav").apply { writeBytes(syntheticWave()) } + VoiceWaveformCache.put(audio.path, FloatArray(120) { 0.8f }) + var transfer by mutableStateOf(null) + compose.setContent { + BitchatTheme(darkTheme = true) { + Surface(color = Color.Black) { VoiceNotePlayer(audio.path, Modifier.width(300.dp), progressOverride = transfer) } + } + } + compose.waitUntil(10_000) { compose.onAllNodesWithText("00:01").fetchSemanticsNodes().size == 1 } + val waveform = compose.onNode(hasStateDescription("Audio waveform")) + fun hasColor(red: Float, green: Float, blue: Float): Boolean { + val pixels = waveform.captureToImage().toPixelMap() + return (0 until pixels.width).any { x -> (0 until pixels.height).any { y -> + val p = pixels[x, y] + kotlin.math.abs(p.red - red) < 0.025f && kotlin.math.abs(p.green - green) < 0.025f && kotlin.math.abs(p.blue - blue) < 0.025f + } } + } + assertTrue("Unplayed waveform must retain translucent green", hasColor(0f, 34f / 255f, 17f / 255f)) + waveform.performTouchInput { click(center) } + assertTrue("Playback progress must remain green", hasColor(0f, 200f / 255f, 81f / 255f)) + compose.runOnIdle { transfer = 0.5f } + assertTrue("Transfer progress must remain blue", hasColor(30f / 255f, 136f / 255f, 229f / 255f)) + } +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/FileAttachmentDialogInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/FileAttachmentDialogInstrumentedTest.kt new file mode 100644 index 00000000..1a8b91e3 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/FileAttachmentDialogInstrumentedTest.kt @@ -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) + } + } +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/SyntheticMeshFixture.kt b/app/src/androidTest/java/com/bitchat/android/ui/SyntheticMeshFixture.kt new file mode 100644 index 00000000..de0c6765 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/SyntheticMeshFixture.kt @@ -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() + "shouldShowEncryptionIcon", "hasEstablishedSession" -> false + "toString" -> "Synthetic mesh" + "hashCode" -> 1 + "equals" -> false + else -> null + } +} as MeshService diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..f1f7d756 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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(null) } - var showFullScreenImageViewer by remember { mutableStateOf(false) } - var viewerImagePaths by remember { mutableStateOf(emptyList()) } - 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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index f0647e6c..998dacdc 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -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(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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f40549b6..fd3f5838 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationTimeline.kt b/app/src/main/java/com/bitchat/android/ui/ConversationTimeline.kt new file mode 100644 index 00000000..0fa63dbd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ConversationTimeline.kt @@ -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, + currentUserNickname: String, + viewModel: ChatViewModel, + context: ConversationUiContext, + onMention: (String) -> Unit, + modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = 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, + currentUserNickname: String, + meshService: com.bitchat.android.mesh.MeshService, + context: ConversationUiContext, + onMention: (String) -> Unit, + onCancelTransfer: (BitchatMessage) -> Unit, + modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + forceScrollToBottom: Boolean = false, + messageActions: @Composable (BitchatMessage, () -> Unit) -> Unit, +) { + var selectedMessage by remember(context.key) { mutableStateOf(null) } + var gallery by remember(context.key) { mutableStateOf, 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 } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 63a5c0a7..b970d655 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -275,8 +275,8 @@ class GeohashViewModel( fun getNostrKeyMapping(): Map = 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 diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c..c3a50e59 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 8eb76c44..40e478fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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) - } - } - } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt b/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt new file mode 100644 index 00000000..fc0732be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.ui.theme.BitchatFontFamily +import java.text.SimpleDateFormat + +/** Reserve the receipt slot before the first status arrives, for text and media alike. */ +@Composable +internal fun MessageMetadata( + message: BitchatMessage, + timeFormatter: SimpleDateFormat, + showDeliveryStatus: Boolean, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text(text = formatTextMessageMetadata(message, timeFormatter), fontFamily = BitchatFontFamily) + if (showDeliveryStatus) { + Spacer(Modifier.width(4.dp)) + val status = message.deliveryStatus + Box( + Modifier.graphicsLayer { alpha = if (status == null) 0f else 1f } + .then(if (status == null) Modifier.clearAndSetSemantics {} else Modifier), + ) { + DeliveryStatusIcon(status ?: DeliveryStatus.Sending) + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 0fc3b622..75db2e24 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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)) - } - } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileAttachmentMessage.kt b/app/src/main/java/com/bitchat/android/ui/media/FileAttachmentMessage.kt new file mode 100644 index 00000000..b1f51f0a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/FileAttachmentMessage.kt @@ -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(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) } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt index 7a289686..a70e9d03 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -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") - } ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt b/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt index 6ef262e5..1007dae6 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileViewerDialog.kt @@ -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)) } + } + }, + ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 5644e094..ef4a3ecf 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, 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(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, @@ -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) } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt index bc69dec3..e372cfa3 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaBubbleShell.kt @@ -7,13 +7,9 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -24,8 +20,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.DeliveryStatusIcon -import com.bitchat.android.ui.formatTextMessageMetadata +import com.bitchat.android.ui.MessageMetadata import com.bitchat.android.ui.formatTextMessageSender import com.bitchat.android.ui.isFromSelf import com.bitchat.android.ui.peerIdentityForMessage @@ -134,21 +129,12 @@ fun MediaBubbleShell( content() - Row( - verticalAlignment = Alignment.CenterVertically, + MessageMetadata( + message = message, + timeFormatter = timeFormatter, + showDeliveryStatus = isSelf && message.isPrivate, modifier = Modifier.align(Alignment.End), - ) { - Text( - text = formatTextMessageMetadata(message, timeFormatter), - fontFamily = BitchatFontFamily, - ) - if (isSelf && message.isPrivate) { - message.deliveryStatus?.let { status -> - Spacer(Modifier.width(4.dp)) - DeliveryStatusIcon(status = status) - } - } - } + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt new file mode 100644 index 00000000..78a5f9b7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaMessageLayout.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 1f3f567e..f37c5c0b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceWaveformLoader.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceWaveformLoader.kt new file mode 100644 index 00000000..6440d707 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceWaveformLoader.kt @@ -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? { + val samples = cached(path) ?: suspendCancellableCoroutine { 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() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt index 64261d47..d272e6c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt @@ -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? = { loadVoiceWaveform(it) }, ) { - val cached = remember(path) { VoiceWaveformCache.get(path) } - val stateSamples = remember { mutableStateListOf() } - 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?>(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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f..bcb92269 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -654,4 +654,13 @@ App language System default Select language + + Live audio + Loading waveform + Waveform unavailable + Audio waveform + Unavailable + Open + Save + Could not open or save this file. Please try again. diff --git a/app/src/test/java/com/bitchat/android/ui/ConversationUiParityTest.kt b/app/src/test/java/com/bitchat/android/ui/ConversationUiParityTest.kt new file mode 100644 index 00000000..52fe68f2 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ConversationUiParityTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/media/FileAttachmentTest.kt b/app/src/test/java/com/bitchat/android/ui/media/FileAttachmentTest.kt new file mode 100644 index 00000000..77348cb1 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/media/FileAttachmentTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/media/VoiceWaveformLoaderTest.kt b/app/src/test/java/com/bitchat/android/ui/media/VoiceWaveformLoaderTest.kt new file mode 100644 index 00000000..775fbf0a --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/media/VoiceWaveformLoaderTest.kt @@ -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() })) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 405f6dc3..ae323007 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4dad1c4d..5f777372 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4771,6 +4771,11 @@ + + + + + @@ -4781,6 +4786,11 @@ + + + + +