mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 1b44e72ecfbec43ba1aca0ab7c06c2b75527b621 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
49b7337e38
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.WaveformPreview
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import java.lang.reflect.Proxy
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import android.graphics.Bitmap
|
||||
import java.io.File
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.ui.theme.ChatUiMode
|
||||
import com.bitchat.android.ui.theme.ChatUiModeManager
|
||||
|
||||
class ChatUiParityInstrumentedTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun waveformReloadsWhenBothOldAndNewSourcesAreUncached() {
|
||||
var path by mutableStateOf("live")
|
||||
var live by mutableStateOf(false)
|
||||
val old = CompletableDeferred<List<Float>?>()
|
||||
val loaded = mutableListOf<String>()
|
||||
compose.setContent {
|
||||
BitchatTheme {
|
||||
WaveformPreview(
|
||||
Modifier.fillMaxWidth().height(32.dp), path, null, null,
|
||||
isLive = live,
|
||||
loadSamples = { source ->
|
||||
loaded.add(source)
|
||||
if (source == "live") old.await() else List(120) { 0.6f }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNode(hasStateDescription("Loading waveform")).assertExists()
|
||||
compose.runOnIdle { path = "finished" }
|
||||
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
|
||||
compose.runOnIdle { old.complete(List(120) { 0f }) }
|
||||
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
|
||||
assertEquals(listOf("live", "finished"), loaded)
|
||||
compose.runOnIdle { live = true }
|
||||
compose.onNode(hasStateDescription("Live audio")).assertExists()
|
||||
compose.runOnIdle { live = false }
|
||||
compose.onNode(hasStateDescription("Audio waveform")).assertExists()
|
||||
assertEquals(listOf("live", "finished", "finished"), loaded)
|
||||
}
|
||||
|
||||
@Test fun privateTimelineWiresMessageActionsAndJumpToLatest() {
|
||||
val messages = (0..35).map { index ->
|
||||
BitchatMessage(id = "synthetic-$index", sender = "alice", content = "Synthetic message $index",
|
||||
senderPeerID = "synthetic-other", timestamp = Date(index * 60_000L), isPrivate = true)
|
||||
}
|
||||
compose.setContent {
|
||||
BitchatTheme {
|
||||
Surface {
|
||||
ConversationTimelineContent(
|
||||
messages, "me", syntheticMesh(), ConversationUiContext("dm", "synthetic-other"),
|
||||
onMention = {}, onCancelTransfer = {}, modifier = Modifier.fillMaxSize(),
|
||||
messageActions = { message, dismiss ->
|
||||
AlertDialog(onDismissRequest = dismiss, text = { Text(message.content) },
|
||||
confirmButton = { TextButton(onClick = dismiss) { Text("Close actions") } })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNode(hasText("Synthetic message 35", substring = true)).performTouchInput { longClick() }
|
||||
compose.onNodeWithText("Close actions").assertExists().performClick()
|
||||
compose.onNode(hasScrollAction()).performScrollToIndex(30)
|
||||
compose.onNodeWithContentDescription("Scroll to bottom").assertExists().performClick()
|
||||
compose.onNode(hasText("Synthetic message 35", substring = true)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun everyConversationOpensImagesAndMatrixImagesSupportLongPress() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val file = File(app.cacheDir, "synthetic-gallery.png")
|
||||
val bitmap = Bitmap.createBitmap(80, 80, Bitmap.Config.ARGB_8888)
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
var context by mutableStateOf(ConversationUiContext("mesh"))
|
||||
compose.setContent {
|
||||
BitchatTheme {
|
||||
Surface {
|
||||
ConversationTimelineContent(
|
||||
listOf(BitchatMessage(id = "synthetic-image", sender = "alice", content = file.path,
|
||||
type = BitchatMessageType.Image, timestamp = Date(0), senderPeerID = "synthetic-other",
|
||||
isPrivate = context.privatePeerID != null)),
|
||||
"me", syntheticMesh(), context, onMention = {}, onCancelTransfer = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
messageActions = { _, dismiss ->
|
||||
AlertDialog(onDismissRequest = dismiss, text = { Text("Image actions") },
|
||||
confirmButton = { TextButton(onClick = dismiss) { Text("Close actions") } })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (nostr in listOf(false, true)) for (private in listOf(false, true)) {
|
||||
compose.runOnIdle {
|
||||
context = ConversationUiContext("$nostr-$private", if (private) "synthetic-other" else null, nostr)
|
||||
}
|
||||
compose.waitUntil(5_000) { compose.onAllNodesWithContentDescription("Image").fetchSemanticsNodes().isNotEmpty() }
|
||||
compose.onNodeWithContentDescription("Image").performClick()
|
||||
compose.onNodeWithContentDescription("Image 1 of 1").assertExists()
|
||||
compose.onNodeWithContentDescription("Close").performClick()
|
||||
}
|
||||
compose.runOnIdle { ChatUiModeManager.set(app, ChatUiMode.Matrix) }
|
||||
compose.onNodeWithContentDescription("Image").performTouchInput { longClick() }
|
||||
compose.onNodeWithText("Image actions").assertExists()
|
||||
compose.onNodeWithText("Close actions").performClick()
|
||||
compose.runOnIdle { ChatUiModeManager.set(app, ChatUiMode.Bubbles) }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ChatVisualStabilityInstrumentedTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun deliveryChangesKeepMessageTimestampAndMediaBoundsStable() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val audio = File(app.cacheDir, "synthetic-stability.wav").apply { writeBytes(syntheticWave()) }
|
||||
val image = File(app.cacheDir, "synthetic-stability.png")
|
||||
Bitmap.createBitmap(80, 40, Bitmap.Config.ARGB_8888).also { bitmap ->
|
||||
image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
}
|
||||
val file = File(app.cacheDir, "synthetic-stability.txt").apply { writeText("Synthetic attachment") }
|
||||
val contents = mapOf(BitchatMessageType.Message to "Synthetic note.", BitchatMessageType.Audio to audio.path,
|
||||
BitchatMessageType.Image to image.path, BitchatMessageType.File to file.path)
|
||||
var textBody by mutableStateOf("Synthetic note.")
|
||||
var cancelled = 0
|
||||
var type by mutableStateOf(BitchatMessageType.Message)
|
||||
var status by mutableStateOf<DeliveryStatus?>(null)
|
||||
var bubbles by mutableStateOf(true)
|
||||
var width by mutableStateOf(320)
|
||||
var self by mutableStateOf(true)
|
||||
val timestamp = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()).format(Date(0))
|
||||
compose.setContent {
|
||||
BitchatTheme(darkTheme = true) {
|
||||
Surface(Modifier.width(width.dp).fillMaxHeight().testTag("capture")) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
MessageItem(
|
||||
BitchatMessage(id = "synthetic-stability", sender = if (self) "me" else "alice",
|
||||
senderPeerID = if (self) "synthetic-self" else "synthetic-other", content = if (type == BitchatMessageType.Message) textBody else contents.getValue(type),
|
||||
type = type, timestamp = Date(0), isPrivate = true, deliveryStatus = status),
|
||||
"me", syntheticMesh(), bubbles = bubbles, onCancelTransfer = { cancelled++ }, modifier = Modifier.testTag("row"),
|
||||
)
|
||||
Text("Next message", Modifier.testTag("next"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val statuses = listOf(null, DeliveryStatus.Sending, DeliveryStatus.PartiallyDelivered(1, 3), DeliveryStatus.Sent,
|
||||
DeliveryStatus.Delivered("synthetic-peer", Date(0)), DeliveryStatus.Read("synthetic-peer", Date(0)), DeliveryStatus.Failed("synthetic"))
|
||||
val differences = mutableListOf<String>()
|
||||
val evidence = File(app.filesDir, "chat-ui-stability").apply { mkdirs() }
|
||||
val record = InstrumentationRegistry.getArguments().getString("recordRegression") == "true"
|
||||
val cases = contents.keys.map { it to "Synthetic note." } + listOf(
|
||||
BitchatMessageType.Message to "Synthetic text near a line boundary",
|
||||
BitchatMessageType.Message to "A longer synthetic message that wraps across several lines to exercise timestamp placement.",
|
||||
)
|
||||
for (logicalWidth in listOf(320, 411)) for (bubble in listOf(true, false)) for (own in listOf(true, false)) for ((kind, body) in cases) {
|
||||
compose.runOnIdle { width = logicalWidth; bubbles = bubble; self = own; type = kind; textBody = body; status = null }
|
||||
if (kind == BitchatMessageType.Audio) compose.waitUntil(10_000) { compose.onAllNodes(hasStateDescription("Audio waveform")).fetchSemanticsNodes().size == 1 }
|
||||
if (kind == BitchatMessageType.Image) compose.waitUntil(10_000) { compose.onAllNodesWithContentDescription("Image").fetchSemanticsNodes().size == 1 }
|
||||
if (kind == BitchatMessageType.File) compose.waitUntil(10_000) { compose.onAllNodesWithText(file.name).fetchSemanticsNodes().size == 1 }
|
||||
fun bounds(): Map<String, String> {
|
||||
val result = mutableMapOf("row" to compose.onNodeWithTag("row").fetchSemanticsNode().boundsInRoot.toString(),
|
||||
"next" to compose.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.toString(),
|
||||
"timestamp" to compose.onNode(hasText(timestamp, substring = true), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString())
|
||||
val media = when (kind) {
|
||||
BitchatMessageType.Audio -> hasStateDescription("Audio waveform")
|
||||
BitchatMessageType.Image -> hasContentDescription("Image")
|
||||
BitchatMessageType.File -> hasText(file.name)
|
||||
else -> hasText(body, substring = true)
|
||||
}
|
||||
result["content"] = compose.onNode(media, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.toString()
|
||||
return result
|
||||
}
|
||||
val initial = bounds()
|
||||
for ((index, nextStatus) in statuses.withIndex()) {
|
||||
// Image/file transfer animations intentionally replace their content; receipt ticks do not.
|
||||
if (nextStatus is DeliveryStatus.PartiallyDelivered && kind != BitchatMessageType.Audio) continue
|
||||
compose.runOnIdle { status = nextStatus }
|
||||
compose.waitForIdle()
|
||||
if (own && kind == BitchatMessageType.Audio && nextStatus is DeliveryStatus.PartiallyDelivered) {
|
||||
val previous = cancelled
|
||||
compose.onNodeWithContentDescription("Cancel").performClick()
|
||||
compose.runOnIdle { assertTrue("Cancel callback must remain wired", cancelled == previous + 1) }
|
||||
}
|
||||
val current = bounds()
|
||||
val label = "$logicalWidth-${if (bubble) "bubbles" else "matrix"}-${if (own) "sent" else "received"}-$kind-$index"
|
||||
initial.forEach { (node, rect) -> if (current[node] != rect) differences += "$label bodyLength=${body.length} $node: $rect -> ${current[node]}" }
|
||||
if (logicalWidth == 320 && own && kind == BitchatMessageType.Audio && index in listOf(0, 2, 3, 5)) {
|
||||
File(evidence, "${if (record) "before" else "after"}-$label.png").outputStream().use {
|
||||
compose.onNodeWithTag("capture").captureToImage().asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File(evidence, "${if (record) "before" else "after"}-bounds.txt").writeText(differences.joinToString("\n"))
|
||||
assertTrue("Delivery status displaced content:\n${differences.take(20).joinToString("\n")}", record || differences.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun playerKeepsGreenPlaybackAndBlueTransferColors() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val audio = File(app.cacheDir, "synthetic-color.wav").apply { writeBytes(syntheticWave()) }
|
||||
VoiceWaveformCache.put(audio.path, FloatArray(120) { 0.8f })
|
||||
var transfer by mutableStateOf<Float?>(null)
|
||||
compose.setContent {
|
||||
BitchatTheme(darkTheme = true) {
|
||||
Surface(color = Color.Black) { VoiceNotePlayer(audio.path, Modifier.width(300.dp), progressOverride = transfer) }
|
||||
}
|
||||
}
|
||||
compose.waitUntil(10_000) { compose.onAllNodesWithText("00:01").fetchSemanticsNodes().size == 1 }
|
||||
val waveform = compose.onNode(hasStateDescription("Audio waveform"))
|
||||
fun hasColor(red: Float, green: Float, blue: Float): Boolean {
|
||||
val pixels = waveform.captureToImage().toPixelMap()
|
||||
return (0 until pixels.width).any { x -> (0 until pixels.height).any { y ->
|
||||
val p = pixels[x, y]
|
||||
kotlin.math.abs(p.red - red) < 0.025f && kotlin.math.abs(p.green - green) < 0.025f && kotlin.math.abs(p.blue - blue) < 0.025f
|
||||
} }
|
||||
}
|
||||
assertTrue("Unplayed waveform must retain translucent green", hasColor(0f, 34f / 255f, 17f / 255f))
|
||||
waveform.performTouchInput { click(center) }
|
||||
assertTrue("Playback progress must remain green", hasColor(0f, 200f / 255f, 81f / 255f))
|
||||
compose.runOnIdle { transfer = 0.5f }
|
||||
assertTrue("Transfer progress must remain blue", hasColor(30f / 255f, 136f / 255f, 229f / 255f))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
internal fun syntheticMesh(): MeshService = Proxy.newProxyInstance(
|
||||
MeshService::class.java.classLoader, arrayOf(MeshService::class.java),
|
||||
) { _, method, _ ->
|
||||
when (method.name) {
|
||||
"getMyPeerID" -> "synthetic-self"
|
||||
"getPeerNicknames" -> emptyMap<String, String>()
|
||||
"shouldShowEncryptionIcon", "hasEstablishedSession" -> false
|
||||
"toString" -> "Synthetic mesh"
|
||||
"hashCode" -> 1
|
||||
"equals" -> false
|
||||
else -> null
|
||||
}
|
||||
} as MeshService
|
||||
@ -7,8 +7,6 @@ import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
|
||||
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
@ -18,19 +16,14 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
@ -42,10 +35,8 @@ import com.bitchat.android.R
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
|
||||
/**
|
||||
@ -90,14 +81,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||
var showLocationNotesSheet by remember { mutableStateOf(false) }
|
||||
var showUserSheet by remember { mutableStateOf(false) }
|
||||
var selectedUserForSheet by remember { mutableStateOf("") }
|
||||
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
||||
var showFullScreenImageViewer by remember { mutableStateOf(false) }
|
||||
var viewerImagePaths by remember { mutableStateOf(emptyList<String>()) }
|
||||
var initialViewerIndex by remember { mutableStateOf(0) }
|
||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||
var isScrolledUp by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(selectedPrivatePeer) {
|
||||
messageText = TextFieldValue(
|
||||
@ -250,11 +234,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
buildMentionPeerIdentityMap(displayMessages, knownPeers)
|
||||
}
|
||||
|
||||
// Determine whether to show media buttons (only hide in geohash location chats)
|
||||
val showMediaButtons = when {
|
||||
currentChannel != null -> true
|
||||
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
|
||||
}
|
||||
val conversationContext = ConversationUiContext(
|
||||
key = conversationKey,
|
||||
isNostr = currentChannel == null && selectedLocationChannel is ChannelID.Location,
|
||||
)
|
||||
val showMediaButtons = conversationContext.supportsMediaSend(hasMeshRoute = false)
|
||||
|
||||
// Use WindowInsets to handle keyboard properly
|
||||
Box(
|
||||
@ -298,66 +282,22 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val showNotesStrip =
|
||||
isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
|
||||
|
||||
MessagesList(
|
||||
ConversationTimeline(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
viewModel = viewModel,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
conversationKey = conversationKey,
|
||||
context = conversationContext,
|
||||
contentPadding = PaddingValues(
|
||||
top = statusBarHeight + headerHeight +
|
||||
(if (showNotesStrip) notesStripHeight else 0.dp),
|
||||
bottom = composerHeight
|
||||
),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
// Single click - mention user in text input
|
||||
val currentText = messageText.text
|
||||
|
||||
// Extract base nickname and hash suffix from full sender name
|
||||
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
||||
|
||||
// Check if we're in a geohash channel to include hash suffix
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
val mentionText = if (
|
||||
selectedLocationChannel is ChannelID.Location &&
|
||||
hashSuffix.isNotEmpty()
|
||||
) {
|
||||
// In geohash chat - include the hash suffix from the full display name
|
||||
"@$baseName$hashSuffix"
|
||||
} else {
|
||||
// Regular chat - just the base nickname
|
||||
"@$baseName"
|
||||
}
|
||||
|
||||
val newText = when {
|
||||
currentText.isEmpty() -> "$mentionText "
|
||||
currentText.endsWith(" ") -> "$currentText$mentionText "
|
||||
else -> "$currentText $mentionText "
|
||||
}
|
||||
|
||||
messageText = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length)
|
||||
)
|
||||
},
|
||||
onMessageLongPress = { message ->
|
||||
// Message long press - open user action sheet with message context
|
||||
// Extract base nickname from message sender (contains all necessary info)
|
||||
val (baseName, _) = splitSuffix(message.sender)
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
onMention = { sender ->
|
||||
messageText = appendConversationMention(messageText, sender, conversationContext)
|
||||
viewModel.setConversationDraft(null, messageText.text)
|
||||
}
|
||||
)
|
||||
|
||||
@ -465,52 +405,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
)
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp,
|
||||
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
|
||||
// away from the messages the user is actually reading.
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { it / 2 }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { it / 2 }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
|
||||
.zIndex(1.5f)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(1.dp, colorScheme.primary)
|
||||
) {
|
||||
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDownward,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
||||
if (showFullScreenImageViewer) {
|
||||
FullScreenImageViewer(
|
||||
imagePaths = viewerImagePaths,
|
||||
initialIndex = initialViewerIndex,
|
||||
onClose = { showFullScreenImageViewer = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Dialogs and Sheets
|
||||
@ -542,13 +436,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
},
|
||||
showLocationNotesSheet = showLocationNotesSheet,
|
||||
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
|
||||
showUserSheet = showUserSheet,
|
||||
onUserSheetDismiss = {
|
||||
showUserSheet = false
|
||||
selectedMessageForSheet = null // Reset message when dismissing
|
||||
},
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
@ -827,10 +714,6 @@ private fun ChatDialogs(
|
||||
onLocationNotesFromChannelsClick: () -> Unit,
|
||||
showLocationNotesSheet: Boolean,
|
||||
onLocationNotesSheetDismiss: () -> Unit,
|
||||
showUserSheet: Boolean,
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
@ -884,16 +767,6 @@ private fun ChatDialogs(
|
||||
)
|
||||
}
|
||||
|
||||
// User action sheet
|
||||
if (showUserSheet) {
|
||||
ChatUserSheet(
|
||||
isPresented = showUserSheet,
|
||||
onDismiss = onUserSheetDismiss,
|
||||
targetNickname = selectedUserForSheet,
|
||||
selectedMessage = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// MeshPeerList sheet (network view)
|
||||
if (showMeshPeerListSheet){
|
||||
MeshPeerListSheet(
|
||||
|
||||
@ -18,6 +18,12 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.ui.media.FileAttachment
|
||||
import com.bitchat.android.ui.media.FileViewerDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* User Action Sheet for selecting actions on a specific user (slap, hug, block)
|
||||
@ -29,11 +35,21 @@ fun ChatUserSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
targetNickname: String,
|
||||
selectedMessage: BitchatMessage? = null,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
conversationContext: ConversationUiContext,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedMessage: BitchatMessage? = null,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val attachment by produceState<FileAttachment?>(null, selectedMessage?.content, selectedMessage?.type) {
|
||||
value = null
|
||||
val message = selectedMessage
|
||||
if (message != null && message.type != BitchatMessageType.Message) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching { FileAttachment.fromPath(message.content.trim()) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
var showAttachmentDialog by remember(selectedMessage?.id) { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@ -76,7 +92,7 @@ fun ChatUserSheet(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Copy message action (only show if we have a message)
|
||||
selectedMessage?.let { message ->
|
||||
selectedMessage?.takeIf { it.type == BitchatMessageType.Message }?.let { message ->
|
||||
item {
|
||||
UserActionRow(
|
||||
title = stringResource(R.string.action_copy_message_title),
|
||||
@ -91,18 +107,30 @@ fun ChatUserSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Only show user actions for other users' messages or when no message is selected
|
||||
if (selectedMessage?.sender != viewModel.nickname.value) {
|
||||
// Send private message action
|
||||
attachment?.let { file ->
|
||||
item {
|
||||
UserActionRow(
|
||||
title = stringResource(R.string.file_viewer_open_save),
|
||||
subtitle = file.fileName,
|
||||
titleColor = standardGrey,
|
||||
onClick = { showAttachmentDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Only show user actions for other users' messages or when no message is selected
|
||||
if (selectedMessage?.isFromSelf(viewModel.nickname.value, viewModel.meshServiceFacade.myPeerID) != true && selectedMessage?.sender != "system") {
|
||||
// Already in this person's private conversation: do not offer opening it again.
|
||||
if (conversationContext.privatePeerID == null) item {
|
||||
UserActionRow(
|
||||
title = stringResource(R.string.action_private_message_title, targetNickname),
|
||||
subtitle = stringResource(R.string.action_private_message_subtitle),
|
||||
titleColor = standardPurple,
|
||||
onClick = {
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
|
||||
if (conversationContext.isNostr) {
|
||||
if (selectedMessage?.senderNostrPubkey != null) {
|
||||
viewModel.startGeohashDM(selectedMessage.senderNostrPubkey)
|
||||
} else if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
|
||||
val shortId = selectedMessage.senderPeerID!!.substring(6)
|
||||
viewModel.startGeohashDMByShortId(shortId)
|
||||
} else {
|
||||
@ -156,13 +184,14 @@ fun ChatUserSheet(
|
||||
titleColor = standardRed,
|
||||
onClick = {
|
||||
// Check if we're in a geohash channel
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Get user's nostr public key and add to geohash block list
|
||||
viewModel.blockUserInGeohash(targetNickname)
|
||||
if (conversationContext.isNostr) {
|
||||
viewModel.blockUserInGeohash(targetNickname, selectedMessage?.senderNostrPubkey)
|
||||
} else {
|
||||
// Regular mesh blocking
|
||||
viewModel.sendMessage("/block $targetNickname")
|
||||
val senderID = selectedMessage?.senderPeerID ?: conversationContext.privatePeerID
|
||||
val meshPeerID = senderID?.let { ContactDirectory.resolve(it).meshPeerID ?: it }
|
||||
if (meshPeerID != null) {
|
||||
viewModel.privateChatManager.blockPeer(meshPeerID, viewModel.meshServiceFacade)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
@ -189,6 +218,15 @@ fun ChatUserSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showAttachmentDialog) {
|
||||
attachment?.let { file ->
|
||||
FileViewerDialog(file) {
|
||||
showAttachmentDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -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
|
||||
|
||||
131
app/src/main/java/com/bitchat/android/ui/ConversationTimeline.kt
Normal file
131
app/src/main/java/com/bitchat/android/ui/ConversationTimeline.kt
Normal file
@ -0,0 +1,131 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
|
||||
/** The visible conversation, independent of the timeline underneath a private sheet. */
|
||||
data class ConversationUiContext(
|
||||
val key: String,
|
||||
val privatePeerID: String? = null,
|
||||
val isNostr: Boolean = false,
|
||||
) {
|
||||
fun supportsMediaSend(hasMeshRoute: Boolean): Boolean =
|
||||
if (privatePeerID != null) hasMeshRoute else !isNostr
|
||||
}
|
||||
|
||||
internal fun appendConversationMention(
|
||||
text: TextFieldValue,
|
||||
sender: String,
|
||||
context: ConversationUiContext,
|
||||
): TextFieldValue {
|
||||
val (name, suffix) = splitSuffix(sender)
|
||||
val mention = "@$name${if (context.isNostr) suffix else ""} "
|
||||
val separator = if (text.text.isEmpty() || text.text.endsWith(" ")) "" else " "
|
||||
val updated = text.text + separator + mention
|
||||
return TextFieldValue(updated, TextRange(updated.length))
|
||||
}
|
||||
|
||||
/** All chat entry points get the same interaction wiring, overlays and scroll control. */
|
||||
@Composable
|
||||
internal fun ConversationTimeline(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
viewModel: ChatViewModel,
|
||||
context: ConversationUiContext,
|
||||
onMention: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
forceScrollToBottom: Boolean = false,
|
||||
) {
|
||||
ConversationTimelineContent(
|
||||
messages, currentUserNickname, viewModel.meshServiceFacade, context, onMention,
|
||||
onCancelTransfer = { viewModel.cancelMediaSend(it.id) },
|
||||
modifier = modifier, mentionPeerIdentities = mentionPeerIdentities,
|
||||
contentPadding = contentPadding, forceScrollToBottom = forceScrollToBottom,
|
||||
messageActions = { message, dismiss ->
|
||||
ChatUserSheet(
|
||||
isPresented = true, onDismiss = dismiss,
|
||||
targetNickname = splitSuffix(message.sender).first,
|
||||
selectedMessage = message, viewModel = viewModel, conversationContext = context,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ConversationTimelineContent(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
context: ConversationUiContext,
|
||||
onMention: (String) -> Unit,
|
||||
onCancelTransfer: (BitchatMessage) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
forceScrollToBottom: Boolean = false,
|
||||
messageActions: @Composable (BitchatMessage, () -> Unit) -> Unit,
|
||||
) {
|
||||
var selectedMessage by remember(context.key) { mutableStateOf<BitchatMessage?>(null) }
|
||||
var gallery by remember(context.key) { mutableStateOf<Pair<List<String>, Int>?>(null) }
|
||||
var scrolledUp by remember(context.key) { mutableStateOf(false) }
|
||||
var scrollRequest by remember(context.key) { mutableStateOf(false) }
|
||||
LaunchedEffect(context.key, forceScrollToBottom) { scrollRequest = !scrollRequest }
|
||||
Box(modifier) {
|
||||
MessagesList(
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
conversationKey = context.key,
|
||||
forceScrollToBottom = scrollRequest,
|
||||
onScrolledUpChanged = { scrolledUp = it },
|
||||
onNicknameClick = onMention,
|
||||
onMessageLongPress = { selectedMessage = it },
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = { _, paths, index ->
|
||||
if (paths.isNotEmpty()) gallery = paths to index.coerceIn(paths.indices)
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = scrolledUp,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = contentPadding.calculateBottomPadding() + 8.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
|
||||
) {
|
||||
IconButton(onClick = { scrollRequest = !scrollRequest }) {
|
||||
Icon(Icons.Default.ArrowDownward, stringResource(R.string.cd_scroll_to_bottom))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gallery?.let { (paths, index) ->
|
||||
FullScreenImageViewer(paths, index, onClose = { gallery = null })
|
||||
}
|
||||
selectedMessage?.let { message ->
|
||||
messageActions(message) { selectedMessage = null }
|
||||
}
|
||||
}
|
||||
@ -275,8 +275,8 @@ class GeohashViewModel(
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||
fun blockUserInGeohash(targetNickname: String, senderPubkey: String? = null) {
|
||||
val pubkey = senderPubkey ?: repo.findPubkeyByNickname(targetNickname)
|
||||
if (pubkey != null) {
|
||||
dataManager.addGeohashBlockedUser(pubkey)
|
||||
// Refresh people list and counts to remove blocked entry immediately
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt
Normal file
40
app/src/main/java/com/bitchat/android/ui/MessageMetadata.kt
Normal file
@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
/** Reserve the receipt slot before the first status arrives, for text and media alike. */
|
||||
@Composable
|
||||
internal fun MessageMetadata(
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showDeliveryStatus: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = formatTextMessageMetadata(message, timeFormatter), fontFamily = BitchatFontFamily)
|
||||
if (showDeliveryStatus) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
val status = message.deliveryStatus
|
||||
Box(
|
||||
Modifier.graphicsLayer { alpha = if (status == null) 0f else 1f }
|
||||
.then(if (status == null) Modifier.clearAndSetSemantics {} else Modifier),
|
||||
) {
|
||||
DeliveryStatusIcon(status ?: DeliveryStatus.Sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Display metadata; file contents are read only when an explicit open/save action needs them. */
|
||||
data class FileAttachment(val path: String, val fileName: String, val fileSize: Long, val mimeType: String) {
|
||||
companion object {
|
||||
fun fromPath(path: String): FileAttachment? {
|
||||
val file = File(path)
|
||||
return if (file.isFile) FileAttachment(
|
||||
path, file.name, file.length(), FileUtils.getMimeTypeFromExtension(file.name),
|
||||
) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun copyTo(output: OutputStream) {
|
||||
File(path).inputStream().use { it.copyTo(output) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FileAttachmentMessage(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
myPeerID: String,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showSender: Boolean,
|
||||
bubbles: Boolean,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val path = message.content.trim()
|
||||
val attachment by produceState<FileAttachment?>(null, path) {
|
||||
value = withContext(Dispatchers.IO) { runCatching { FileAttachment.fromPath(path) }.getOrNull() }
|
||||
}
|
||||
val progress = mediaTransferProgress(message, message.isFromSelf(currentUserNickname, myPeerID))
|
||||
MediaMessageLayout(
|
||||
message, currentUserNickname, myPeerID, timeFormatter,
|
||||
showSender, bubbles, onNicknameClick, onMessageLongPress, modifier,
|
||||
) {
|
||||
Box {
|
||||
val file = attachment
|
||||
when {
|
||||
file == null -> Text(stringResource(R.string.file_unavailable), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
progress != null -> FileSendingAnimation(fileName = file.fileName, progress = progress)
|
||||
else -> FileMessageItem(
|
||||
attachment = file,
|
||||
onLongPress = onMessageLongPress?.let { action -> { action(message) } },
|
||||
)
|
||||
}
|
||||
if (progress != null && onCancelTransfer != null) {
|
||||
Box(Modifier.align(Alignment.TopEnd)) {
|
||||
CancelMediaTransferButton { onCancelTransfer(message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -18,27 +12,24 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
|
||||
@Composable
|
||||
fun ImageMessageItem(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
myPeerID: String,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
@ -48,99 +39,35 @@ fun ImageMessageItem(
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
// Bubble mode wraps the image in the same tinted shell as text bubbles; Matrix mode keeps
|
||||
// the flat header-plus-card layout.
|
||||
val bubblesMode = bubbles
|
||||
val imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
|
||||
val context = LocalContext.current
|
||||
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
|
||||
|
||||
// Collect all image paths from messages for swipe navigation
|
||||
val imagePaths = remember(messages) {
|
||||
val bmp by produceState<android.graphics.Bitmap?>(null, path) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching { android.graphics.BitmapFactory.decodeFile(path) }.getOrNull()
|
||||
}
|
||||
}
|
||||
val imagePaths = remember(messages, path) {
|
||||
messages.filter { it.type == BitchatMessageType.Image }
|
||||
.map { it.content.trim() }
|
||||
.map { it.content.trim() }.ifEmpty { listOf(path) }
|
||||
}
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
if (bubblesMode) {
|
||||
MediaBubbleShell(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
myPeerID = meshService.myPeerID,
|
||||
showSender = showSender,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
modifier = modifier,
|
||||
) {
|
||||
ImageMessageCard(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
path = path,
|
||||
bmp = bmp,
|
||||
imagePaths = imagePaths,
|
||||
imageShape = imageShape,
|
||||
onImageClick = onImageClick,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
MediaMessageLayout(
|
||||
message, currentUserNickname, myPeerID, timeFormatter,
|
||||
showSender, bubbles, onNicknameClick, onMessageLongPress, modifier,
|
||||
) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
ImageMessageCard(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
myPeerID = meshService.myPeerID,
|
||||
palette = palette,
|
||||
contentColor = colorScheme.onSurface,
|
||||
timeFormatter = timeFormatter,
|
||||
includeSender = showSender
|
||||
isSelf = message.isFromSelf(currentUserNickname, myPeerID),
|
||||
path = path,
|
||||
bmp = bmp,
|
||||
imagePaths = imagePaths,
|
||||
imageShape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp),
|
||||
onImageClick = onImageClick,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onLongPress = onMessageLongPress?.let { action -> {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
action(message)
|
||||
} },
|
||||
)
|
||||
AnnotatedClickableText(
|
||||
text = headerText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = { onMessageLongPress?.invoke(message) },
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
|
||||
if (bmp != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
ImageMessageCard(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
path = path,
|
||||
bmp = bmp,
|
||||
imagePaths = imagePaths,
|
||||
imageShape = imageShape,
|
||||
onImageClick = onImageClick,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onLongPress = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,7 +75,7 @@ fun ImageMessageItem(
|
||||
@Composable
|
||||
private fun ImageMessageCard(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
isSelf: Boolean,
|
||||
path: String,
|
||||
bmp: android.graphics.Bitmap?,
|
||||
imagePaths: List<String>,
|
||||
@ -163,10 +90,7 @@ private fun ImageMessageCard(
|
||||
}
|
||||
val img = bmp.asImageBitmap()
|
||||
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
|
||||
val progressFraction: Float? = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
|
||||
else -> null
|
||||
}
|
||||
val progressFraction = mediaTransferProgress(message, isSelf)
|
||||
Box {
|
||||
val imageModifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
@ -179,7 +103,7 @@ private fun ImageMessageCard(
|
||||
},
|
||||
onLongClick = { onLongPress?.invoke() },
|
||||
)
|
||||
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
|
||||
if (progressFraction != null && progressFraction < 1f && isSelf) {
|
||||
// Cyberpunk block-reveal while sending
|
||||
BlockRevealImage(
|
||||
bitmap = img,
|
||||
@ -197,19 +121,9 @@ private fun ImageMessageCard(
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
// Cancel button overlay during sending
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
||||
.clickable { onCancelTransfer?.invoke(message) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
if (progressFraction != null && onCancelTransfer != null) {
|
||||
Box(Modifier.align(Alignment.TopEnd)) {
|
||||
CancelMediaTransferButton { onCancelTransfer(message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.android.features.voice.resampleWave
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
/** Awaiting the callback makes cancellation discard results from a previous media source. */
|
||||
internal suspend fun loadVoiceWaveform(
|
||||
path: String,
|
||||
cached: (String) -> FloatArray? = VoiceWaveformCache::get,
|
||||
extract: (String, (FloatArray?) -> Unit) -> Unit = { source, complete ->
|
||||
AudioWaveformExtractor.extractAsync(source, sampleCount = 120) { samples ->
|
||||
samples?.let { VoiceWaveformCache.put(source, it) }
|
||||
complete(samples)
|
||||
}
|
||||
},
|
||||
): List<Float>? {
|
||||
val samples = cached(path) ?: suspendCancellableCoroutine<FloatArray?> { continuation ->
|
||||
extract(path) { result ->
|
||||
if (continuation.isActive) continuation.resume(result)
|
||||
}
|
||||
}
|
||||
return samples?.takeIf { it.isNotEmpty() }?.let {
|
||||
(if (it.size == 120) it else resampleWave(it, 120)).toList()
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@ -18,9 +25,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.android.features.voice.resampleWave
|
||||
|
||||
@Composable
|
||||
fun ScrollingWaveformRecorder(
|
||||
@ -51,35 +55,52 @@ fun WaveformPreview(
|
||||
sendProgress: Float?,
|
||||
playbackProgress: Float?,
|
||||
onLoaded: ((FloatArray) -> Unit)? = null,
|
||||
onSeek: ((Float) -> Unit)? = null
|
||||
onSeek: ((Float) -> Unit)? = null,
|
||||
isLive: Boolean = false,
|
||||
progressColor: Color? = null,
|
||||
loadSamples: suspend (String) -> List<Float>? = { loadVoiceWaveform(it) },
|
||||
) {
|
||||
val cached = remember(path) { VoiceWaveformCache.get(path) }
|
||||
val stateSamples = remember { mutableStateListOf<Float>() }
|
||||
val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f
|
||||
LaunchedEffect(cached) {
|
||||
if (cached != null) {
|
||||
val normalized = if (cached.size != 120) resampleWave(cached, 120) else cached
|
||||
stateSamples.clear(); stateSamples.addAll(normalized.toList())
|
||||
} else {
|
||||
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
|
||||
if (arr != null) {
|
||||
VoiceWaveformCache.put(path, arr)
|
||||
stateSamples.clear(); stateSamples.addAll(arr.toList())
|
||||
onLoaded?.invoke(arr)
|
||||
}
|
||||
}
|
||||
val latestOnLoaded by rememberUpdatedState(onLoaded)
|
||||
// The live file is still being written. Decode only once it has been finalized.
|
||||
// Both keys matter: finalization may replace the path or finish the same file.
|
||||
val samples by produceState<List<Float>?>(null, path, isLive) {
|
||||
value = null
|
||||
if (!isLive) {
|
||||
value = loadSamples(path).also { loaded ->
|
||||
loaded?.let { latestOnLoaded?.invoke(it.toFloatArray()) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f
|
||||
val description = stringResource(when {
|
||||
isLive -> R.string.voice_waveform_live
|
||||
samples == null -> R.string.voice_waveform_loading
|
||||
samples!!.isEmpty() -> R.string.voice_waveform_unavailable
|
||||
else -> R.string.voice_waveform_ready
|
||||
})
|
||||
val stateSamples = samples.orEmpty()
|
||||
|
||||
if (!isLive && samples == null) {
|
||||
LinearProgressIndicator(modifier = modifier.semantics { stateDescription = description })
|
||||
return
|
||||
}
|
||||
if (!isLive && stateSamples.isEmpty()) {
|
||||
Text(
|
||||
text = description,
|
||||
modifier = modifier.semantics { stateDescription = description },
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
return
|
||||
}
|
||||
WaveformCanvas(
|
||||
modifier = modifier,
|
||||
samples = stateSamples,
|
||||
modifier = modifier.semantics { stateDescription = description },
|
||||
samples = stateSamples.ifEmpty { List(40) { 0.08f } },
|
||||
fillProgress = if (stateSamples.isEmpty()) 0f else progress,
|
||||
baseColor = Color(0x2200FF7F),
|
||||
fillColor = when {
|
||||
sendProgress != null -> Color(0xFF1E88E5) // blue while sending
|
||||
else -> Color(0xFF00C851) // green during playback
|
||||
},
|
||||
onSeek = onSeek
|
||||
fillColor = progressColor ?: if (sendProgress != null) Color(0xFF1E88E5) else Color(0xFF00C851),
|
||||
onSeek = if (isLive) null else onSeek
|
||||
)
|
||||
}
|
||||
|
||||
@ -113,7 +134,6 @@ private fun WaveformCanvas(
|
||||
if (n <= 0) return@Canvas
|
||||
val stepX = w / n
|
||||
val midY = h / 2f
|
||||
val radius = 2.dp.toPx()
|
||||
val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)
|
||||
val filledUntil = (n * fillProgress).toInt()
|
||||
for (i in 0 until n) {
|
||||
@ -123,7 +143,7 @@ private fun WaveformCanvas(
|
||||
val yTop = midY - lineH / 2f
|
||||
val yBot = midY + lineH / 2f
|
||||
drawLine(
|
||||
color = if (i <= filledUntil) fillColor else baseColor,
|
||||
color = if (i < filledUntil) fillColor else baseColor,
|
||||
start = Offset(x, yTop),
|
||||
end = Offset(x, yBot),
|
||||
strokeWidth = stroke.width,
|
||||
|
||||
@ -654,4 +654,13 @@
|
||||
<string name="about_app_language">App language</string>
|
||||
<string name="about_system_default">System default</string>
|
||||
<string name="about_select_language">Select language</string>
|
||||
<!-- New media states use the English fallback until localized translations are available. -->
|
||||
<string name="voice_waveform_live" tools:ignore="MissingTranslation">Live audio</string>
|
||||
<string name="voice_waveform_loading" tools:ignore="MissingTranslation">Loading waveform</string>
|
||||
<string name="voice_waveform_unavailable" tools:ignore="MissingTranslation">Waveform unavailable</string>
|
||||
<string name="voice_waveform_ready" tools:ignore="MissingTranslation">Audio waveform</string>
|
||||
<string name="voice_unavailable" tools:ignore="MissingTranslation">Unavailable</string>
|
||||
<string name="attachment_open" tools:ignore="MissingTranslation">Open</string>
|
||||
<string name="attachment_save" tools:ignore="MissingTranslation">Save</string>
|
||||
<string name="attachment_failed" tools:ignore="MissingTranslation">Could not open or save this file. Please try again.</string>
|
||||
</resources>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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() }))
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
@ -4771,6 +4771,11 @@
|
||||
<sha256 value="f65860bce58a2bfadf05fdc516d38333421f0e387fef87e24449c0394c80d254" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.6.4">
|
||||
<artifact name="kotlinx-coroutines-bom-1.6.4.pom">
|
||||
<sha256 value="ab2614855fba66aa8a42514dbe3d5a884315ffe1ed63f5932e710a8006245ce1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.7.3">
|
||||
<artifact name="kotlinx-coroutines-bom-1.7.3.pom">
|
||||
<sha256 value="4e5d1900e6379ef3f5970d04a8f30529adc82f859e8cc107c21ce8149ef666c4" origin="Generated by Gradle"/>
|
||||
@ -4781,6 +4786,11 @@
|
||||
<sha256 value="1239e9dbe1397cd5971342956b2511bc3ace7b641842e4372a088dcfa8b9ad55" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.8.1">
|
||||
<artifact name="kotlinx-coroutines-bom-1.8.1.pom">
|
||||
<sha256 value="563e4aa29fa8fe09a6e1746d0a5b51308f7c7dedc468dc5a0ca810a485877030" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-bom" version="1.9.0">
|
||||
<artifact name="kotlinx-coroutines-bom-1.9.0.pom">
|
||||
<sha256 value="bea5511e9001f2c593ab5080df131b219ab5e2085cc5979ce583eacd4946fd78" origin="Generated by Gradle"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user