Media transfers (#440)

* tor voice wip

* worky BLE a bit

* can send sound

* remove tor

* ui cleanup

* recording time

* progress bar color

* nicknames for audio

* onboarding permissions no microphone

* may work

* fix destionation

* extend

* refactor voice input component

* fix keyboard collapse issue

* send images

* wip image open

* image sending works

* wip waveforms

* better

* better animation

* fix cursor for sending audio

* image sending animation

* image sending animation

* full screen image viewer

* gossip sync for fragments too

* reduce delays

* fix keyboard focus

* use v2 for file transfers

* do not sync fragments

* scrollable image viewer

* ui

* ui adjustments

* nicer animation

* seek through audio

* add spec

* add more details to documentation

* File sharing E2E:
- Add TLV BitchatFilePacket, FileSharingManager
- Implement sendFileNote in ChatViewModel
- File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer
- SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions
- Add FileViewerDialog with system open/save, FileProvider and file_paths
- Hook transfer progress to file sending UI
- Manifest: READ_MEDIA_* and FileProvider
- Fix MessageHandler saving and prefix for non-image payloads
- Add helper utils (FileUtils)

* kinda wip

* fix buttons

* files half working

* wip file transfer

* file packet has 2-byte TLV and chunks. it wokrs but it sucks

* clean

* remove gossip sync for fragments

* fix audio and image rendering

* adjust FILE_SIZE TLV size too

* cleanup

* haptic

* private messages media

* read receipts for media

* use enum for message type not string

* delivery ack checks dont push content

* check

* animation fix

* refactor

* ui adjustments

* comments

* refactor

* fix crash on send and receive of the same file

* refactor notifications

* tests
This commit is contained in:
callebtc
2025-09-19 22:46:14 +02:00
committed by GitHub
parent 1178fc254a
commit 633a506753
57 changed files with 4801 additions and 138 deletions
+19
View File
@@ -20,6 +20,15 @@
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Storage permissions for file sharing -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Haptic feedback permission -->
<uses-permission android:name="android.permission.VIBRATE" />
@@ -41,6 +50,16 @@
android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="31">
<!-- FileProvider for sharing temp/cache files with external viewers -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".ui.GeohashPickerActivity"
android:exported="false"
@@ -138,6 +138,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
}
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.lowercase()
@@ -150,6 +151,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
}
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
fun findNostrPubkeyForPeerID(peerID: String): String? {
return peerIdIndex[peerID.lowercase()]
@@ -0,0 +1,274 @@
package com.bitchat.android.features.file
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
object FileUtils {
private const val TAG = "FileUtils"
/**
* Save a file from URI to app's file directory with unique filename
*/
fun saveFileFromUri(
context: Context,
uri: Uri,
originalName: String? = null
): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) {
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
return null
}
Log.d(TAG, "📂 Opened input stream successfully")
// Determine file extension
val extension = originalName?.substringAfterLast(".") ?: "bin"
val fileName = "file_${System.currentTimeMillis()}.$extension"
// Create incoming dir if needed
val incomingDir = File(context.filesDir, "files/incoming").apply {
if (!exists()) mkdirs()
}
val file = File(incomingDir, fileName)
inputStream.use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "Saved file to: ${file.absolutePath}")
file.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to save file from URI", e)
null
}
}
/**
* Copy file to app's outgoing directory for sending
*/
fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? {
Log.d(TAG, "🔄 Starting file copy from URI: $uri")
return try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) {
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
return null
}
Log.d(TAG, "📂 Opened input stream successfully")
// Determine original filename and extension if available
val displayName = originalName ?: run {
try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
}
} catch (_: Exception) { null }
}
val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() }
?: run {
// Try mime type to extension
val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null }
android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
}
// Preserve original filename (without artificial prefixes), ensure uniqueness
val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_")
?: "file"
var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName
// Create outgoing dir if needed
val outgoingDir = File(context.filesDir, "files/outgoing").apply {
if (!exists()) mkdirs()
}
var target = File(outgoingDir, fileName)
if (target.exists()) {
var idx = 1
val pureBase = baseName
val dotExt = if (extension.isNotBlank()) ".${extension}" else ""
while (target.exists() && idx < 1000) {
fileName = "$pureBase ($idx)$dotExt"
target = File(outgoingDir, fileName)
idx++
}
}
inputStream.use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}")
Log.d(TAG, "📊 Final file size: ${target.length()} bytes")
target.absolutePath
} catch (e: Exception) {
Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e)
Log.e(TAG, "❌ Source URI: $uri")
Log.e(TAG, "❌ Original name: $originalName")
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
null
}
}
/**
* Get MIME type for a file based on extension
*/
fun getMimeTypeFromExtension(fileName: String): String {
return when (fileName.substringAfterLast(".", "").lowercase()) {
"pdf" -> "application/pdf"
"doc" -> "application/msword"
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"xls" -> "application/vnd.ms-excel"
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"ppt" -> "application/vnd.ms-powerpoint"
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
"txt" -> "text/plain"
"json" -> "application/json"
"xml" -> "application/xml"
"csv" -> "text/csv"
"html", "htm" -> "text/html"
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
"webp" -> "image/webp"
"svg" -> "image/svg+xml"
"mp3" -> "audio/mpeg"
"wav" -> "audio/wav"
"m4a" -> "audio/mp4"
"mp4" -> "video/mp4"
"avi" -> "video/x-msvideo"
"mov" -> "video/quicktime"
"zip" -> "application/zip"
"rar" -> "application/vnd.rar"
"7z" -> "application/x-7z-compressed"
else -> "application/octet-stream"
}
}
/**
* Format file size for display
*/
fun formatFileSize(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var size = bytes.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024.0
unitIndex++
}
return "%.1f %s".format(size, units[unitIndex])
}
/**
* Check if file is viewable in system viewer
*/
fun isFileViewable(fileName: String): Boolean {
val extension = fileName.substringAfterLast(".", "").lowercase()
return extension in listOf(
"pdf", "txt", "json", "xml", "html", "htm", "csv",
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
)
}
/**
* Save an incoming file packet to app storage and return absolute path.
* Mirrors existing behavior used in MessageHandler (preserves names and folders).
*/
fun saveIncomingFile(
context: Context,
file: com.bitchat.android.model.BitchatFilePacket
): String {
val lowerMime = file.mimeType.lowercase()
val isImage = lowerMime.startsWith("image/")
val baseDir = context.filesDir
val subdir = if (isImage) "images/incoming" else "files/incoming"
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
fun extFromMime(m: String): String = when (m.lowercase()) {
"image/jpeg", "image/jpg" -> ".jpg"
"image/png" -> ".png"
"image/webp" -> ".webp"
"application/pdf" -> ".pdf"
"text/plain" -> ".txt"
else -> if (isImage) ".jpg" else ".bin"
}
// Prefer transmitted original name; ensure uniqueness to avoid overwrites
val baseName = (file.fileName.takeIf { it.isNotBlank() }
?: (if (isImage) "img" else "file"))
.replace(Regex("[^A-Za-z0-9._-]"), "_")
val ext = extFromMime(lowerMime)
var safeName = if (baseName.contains('.')) baseName else baseName + ext
var idx = 1
while (java.io.File(dir, safeName).exists() && idx < 1000) {
val dot = safeName.lastIndexOf('.')
safeName = if (dot > 0) {
val b = safeName.substring(0, dot)
val e = safeName.substring(dot)
"$b ($idx)$e"
} else {
"$safeName ($idx)"
}
idx++
}
return try {
val out = java.io.File(dir, safeName)
out.outputStream().use { it.write(file.content) }
out.absolutePath
} catch (_: Exception) {
// Fallback to cache dir with uniqueness
try {
var fallback = safeName
var idx2 = 1
while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) {
val dot = fallback.lastIndexOf('.')
fallback = if (dot > 0) {
val b = fallback.substring(0, dot)
val e = fallback.substring(dot)
"$b ($idx2)$e"
} else {
"$fallback ($idx2)"
}
idx2++
}
val out = java.io.File(context.cacheDir, fallback)
out.outputStream().use { it.write(file.content) }
out.absolutePath
} catch (_: Exception) {
val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin")
tmp.writeBytes(file.content)
tmp.absolutePath
}
}
}
/**
* Classify BitchatMessageType from MIME string used in file messages.
*/
fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType {
val lower = mime.lowercase()
return when {
lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image
lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio
else -> com.bitchat.android.model.BitchatMessageType.File
}
}
}
@@ -0,0 +1,36 @@
package com.bitchat.android.features.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import java.io.FileOutputStream
object ImageUtils {
fun downscaleAndSaveToAppFiles(context: Context, uri: android.net.Uri, maxDim: Int = 512, quality: Int = 85): String? {
return try {
val resolver = context.contentResolver
val input = resolver.openInputStream(uri) ?: return null
val original = BitmapFactory.decodeStream(input)
input.close()
original ?: return null
val w = original.width
val h = original.height
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / scale).toInt().coerceAtLeast(1)
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(original, newW, newH, true) else original
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
if (scaled !== original) try { original.recycle() } catch (_: Exception) {}
try { if (scaled != original) scaled.recycle() } catch (_: Exception) {}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,76 @@
package com.bitchat.android.features.voice
import android.content.Context
import android.media.MediaRecorder
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
* The resulting file has MIME audio/mp4.
*/
class VoiceRecorder(private val context: Context) {
companion object { private const val TAG = "VoiceRecorder" }
private var recorder: MediaRecorder? = null
private val _amplitude = MutableStateFlow(0)
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
private var outFile: File? = null
fun start(): File? {
stop() // ensure previous session closed
return try {
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
val file = File(dir, name)
val rec = MediaRecorder()
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(44100)
// Lower bitrate to keep BLE payloads <= 32KiB for fragmentation
rec.setAudioEncodingBitRate(32_000)
rec.setOutputFile(file.absolutePath)
rec.prepare()
rec.start()
recorder = rec
outFile = file
file
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording: ${e.message}")
null
}
}
fun pollAmplitude(): Int {
return try {
val amp = recorder?.maxAmplitude ?: 0
_amplitude.value = amp
amp
} catch (_: Exception) { 0 }
}
fun stop(): File? {
try {
recorder?.apply {
try { stop() } catch (_: Exception) {}
try { reset() } catch (_: Exception) {}
try { release() } catch (_: Exception) {}
}
} catch (_: Exception) {}
val f = outFile
recorder = null
outFile = null
return f
}
}
@@ -0,0 +1,42 @@
package com.bitchat.android.features.voice
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.dp
import kotlin.math.min
@Composable
fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) {
val norm = min(1f, amplitude / 20_000f)
val heightFrac by animateFloatAsState(
targetValue = 0.1f + 0.9f * norm,
animationSpec = tween(120, easing = LinearEasing), label = "amp"
)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
) {
val w = size.width
val h = size.height
val barCount = 24
val gap = 6f
val bw = (w - gap * (barCount - 1)) / barCount
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount)
val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h
val x = i * (bw + gap)
val y = (h - barH) / 2f
drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH))
}
}
}
@@ -0,0 +1,174 @@
package com.bitchat.android.features.voice
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
object VoiceWaveformCache {
private val map = ConcurrentHashMap<String, FloatArray>()
fun put(path: String, samples: FloatArray) { map[path] = samples }
fun get(path: String): FloatArray? = map[path]
}
fun normalizeAmplitudeSample(amp: Int): Float {
val a = max(0, amp)
val norm = ln(1.0 + a.toDouble()) / ln(1.0 + 32768.0)
return norm.toFloat().coerceIn(0f, 1f)
}
fun resampleWave(values: FloatArray, target: Int): FloatArray {
if (values.isEmpty() || target <= 0) return FloatArray(target) { 0f }
if (values.size == target) return values
val out = FloatArray(target)
val step = (values.size - 1).toFloat() / (target - 1).toFloat()
var x = 0f
for (i in 0 until target) {
val idx = x.toInt()
val frac = x - idx
val a = values[idx]
val b = values[min(values.size - 1, idx + 1)]
out[i] = (a + (b - a) * frac).coerceIn(0f, 1f)
x += step
}
return out
}
object AudioWaveformExtractor {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun extractAsync(path: String, sampleCount: Int = 120, onComplete: (FloatArray?) -> Unit) {
scope.launch {
onComplete(runCatching { extract(path, sampleCount) }.getOrNull())
}
}
private fun extract(path: String, sampleCount: Int): FloatArray? {
val extractor = MediaExtractor()
extractor.setDataSource(path)
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
val fmt = extractor.getTrackFormat(idx)
val mime = fmt.getString(MediaFormat.KEY_MIME) ?: ""
mime.startsWith("audio/")
} ?: return null
extractor.selectTrack(trackIndex)
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
codec.start()
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
val desiredBins = sampleCount.coerceAtLeast(32)
val bins = FloatArray(desiredBins) { 0f }
val counts = IntArray(desiredBins) { 0 }
val inBuffers = codec.inputBuffers
val outInfo = MediaCodec.BufferInfo()
var sawEOS = false
while (!sawEOS) {
// Queue input
val inIndex = codec.dequeueInputBuffer(10_000)
if (inIndex >= 0) {
val buffer = codec.getInputBuffer(inIndex) ?: inBuffers[inIndex]
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) {
codec.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
} else {
val presentationTimeUs = extractor.sampleTime
codec.queueInputBuffer(inIndex, 0, sampleSize, presentationTimeUs, 0)
extractor.advance()
}
}
// Dequeue output
var outIndex = codec.dequeueOutputBuffer(outInfo, 10_000)
while (outIndex >= 0) {
val outBuf = codec.getOutputBuffer(outIndex)
if (outBuf != null && outInfo.size > 0) {
outBuf.order(ByteOrder.LITTLE_ENDIAN)
val shortCount = outInfo.size / 2
val shorts = ShortArray(shortCount)
outBuf.asShortBuffer().get(shorts)
// Map this buffer to bins using timestamp range
val startUs = outInfo.presentationTimeUs
val endUs = startUs + bufferDurationUs(format, outInfo.size)
val startBin = binForTime(startUs, durationUs, desiredBins)
val endBin = binForTime(endUs, durationUs, desiredBins).coerceAtMost(desiredBins - 1)
var idx = 0
for (bin in startBin..endBin) {
// aggregate portion of buffer to this bin
val window = shorts.size / max(1, (endBin - startBin + 1))
val begin = idx
val finish = min(shorts.size, idx + window)
var acc = 0.0
var cnt = 0
for (i in begin until finish) {
acc += abs(shorts[i].toInt())
cnt += 1
}
val avg = if (cnt > 0) (acc / cnt) else 0.0
val norm = (avg / 32768.0).coerceIn(0.0, 1.0).toFloat()
bins[bin] = max(bins[bin], norm)
counts[bin] += 1
idx += window
}
}
codec.releaseOutputBuffer(outIndex, false)
outIndex = codec.dequeueOutputBuffer(outInfo, 0)
}
if (outInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
sawEOS = true
}
}
codec.stop()
codec.release()
extractor.release()
// Smooth + normalize
var maxVal = 0f
for (i in bins.indices) {
if (counts[i] == 0) continue
maxVal = max(maxVal, bins[i])
}
if (maxVal <= 0f) maxVal = 1f
for (i in bins.indices) {
bins[i] = (bins[i] / maxVal).coerceIn(0f, 1f)
}
return bins
}
private fun bufferDurationUs(format: MediaFormat, bytes: Int): Long {
return try {
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
val samples = bytes / 2 / max(1, channels)
(samples * 1_000_000L) / max(1, sampleRate)
} catch (e: Exception) {
0L
}
}
private fun binForTime(presentationUs: Long, durationUs: Long, bins: Int): Int {
if (durationUs <= 0L) return 0
val frac = presentationUs.toDouble() / durationUs.toDouble()
return (frac * bins).toInt().coerceIn(0, bins - 1)
}
}
@@ -29,7 +29,7 @@ class BluetoothConnectionManager(
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
// Power management
private val powerManager = PowerManager(context)
private val powerManager = PowerManager(context.applicationContext)
// Coroutines
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -248,6 +248,10 @@ class BluetoothConnectionManager(
)
}
fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId)
}
/**
* Send a packet directly to a specific peer, without broadcasting to others.
*/
@@ -48,7 +48,7 @@ class BluetoothMeshService(private val context: Context) {
private val fragmentManager = FragmentManager()
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
@@ -452,6 +452,13 @@ class BluetoothMeshService(private val context: Context) {
}
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
// Track broadcast fragments for gossip sync
try {
val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
gossipSyncManager.onPublicPacketSeen(packet)
}
} catch (_: Exception) { }
return fragmentManager.handleFragment(packet)
}
@@ -609,6 +616,119 @@ class BluetoothMeshService(private val context: Context) {
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
}
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
val payload = file.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet in sendFileBroadcast")
return
}
Log.d(TAG, "📦 Encoded payload: ${payload.size} bytes")
serviceScope.launch {
val packet = BitchatPacket(
version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the file TLV payload for progress tracking
val transferId = sha256Hex(payload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFileBroadcast failed: ${e.message}", e)
Log.e(TAG, "❌ File: name=${file.fileName}, size=${file.fileSize}")
}
}
/**
* Send a file as an encrypted private message using Noise protocol
*/
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
serviceScope.launch {
// Check if we have an established Noise session
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
// Encode the file packet as TLV
val filePayload = file.encode()
if (filePayload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return@launch
}
Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes")
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
val noisePayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
data = filePayload
)
// Encrypt the payload using Noise
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
if (encrypted == null) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
return@launch
}
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = 7u
)
// Sign and send the encrypted packet
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
val transferId = sha256Hex(filePayload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e)
}
} else {
// No session - initiate handshake but don't queue file
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
}
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
}
}
fun cancelFileTransfer(transferId: String): Boolean {
return connectionManager.cancelTransfer(transferId)
}
// Local helper to hash payloads to a stable hex ID for progress mapping
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Send private message - SIMPLIFIED iOS-compatible version
@@ -18,6 +18,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.Job
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.channels.actor
/**
@@ -100,6 +102,7 @@ class BluetoothPacketBroadcaster(
// Actor scope for the broadcaster
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val transferJobs = ConcurrentHashMap<String, Job>()
// SERIALIZATION: Actor to serialize all broadcast operations
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
@@ -122,24 +125,70 @@ class BluetoothPacketBroadcaster(
characteristic: BluetoothGattCharacteristic?
) {
val packet = routed.packet
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
// Check if we need to fragment
if (fragmentManager != null) {
val fragments = fragmentManager.createFragments(packet)
val fragments = try {
fragmentManager.createFragments(packet)
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
if (isFile) {
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
}
return
}
if (fragments.size > 1) {
if (isFile) {
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
}
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
connectionScope.launch {
if (transferId != null) {
TransferProgressManager.start(transferId, fragments.size)
}
val job = connectionScope.launch {
var sent = 0
fragments.forEach { fragment ->
broadcastSinglePacket(RoutedPacket(fragment), gattServer, characteristic)
// 20ms delay between fragments (matching iOS/Rust)
delay(200)
if (!isActive) return@launch
// If cancelled, stop sending remaining fragments
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
// 20ms delay between fragments
delay(20)
if (transferId != null) {
sent += 1
TransferProgressManager.progress(transferId, sent, fragments.size)
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
}
}
}
if (transferId != null) {
transferJobs[transferId] = job
job.invokeOnCompletion { transferJobs.remove(transferId) }
}
return
}
}
// Send single packet if no fragmentation needed
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
broadcastSinglePacket(routed, gattServer, characteristic)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()
return true
}
/**
@@ -154,6 +203,15 @@ class BluetoothPacketBroadcaster(
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData() ?: return false
val isFile = packet.type == MessageType.FILE_TRANSFER.value
if (isFile) {
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
}
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
val incomingAddr = routed.relayAddress
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
@@ -166,6 +224,10 @@ class BluetoothPacketBroadcaster(
if (serverTarget != null) {
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@@ -176,6 +238,10 @@ class BluetoothPacketBroadcaster(
if (clientTarget != null) {
if (writeToDeviceConn(clientTarget, data)) {
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
if (transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
}
return true
}
}
@@ -183,6 +249,12 @@ class BluetoothPacketBroadcaster(
return false
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Public entry point for broadcasting - submits request to actor for serialization
@@ -48,10 +48,23 @@ class FragmentManager {
* Matches iOS sendFragmentedPacket() implementation exactly
*/
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
val encoded = packet.toBinaryData() ?: return emptyList()
try {
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
val encoded = packet.toBinaryData()
if (encoded == null) {
Log.e(TAG, "❌ Failed to encode packet to binary data")
return emptyList()
}
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = MessagePadding.unpad(encoded)
val fullData = try {
MessagePadding.unpad(encoded)
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
return emptyList()
}
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
@@ -98,7 +111,13 @@ class FragmentManager {
fragments.add(fragmentPacket)
}
return fragments
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
return fragments
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
Log.e(TAG, "❌ Packet type: ${packet.type}, payload: ${packet.payload.size} bytes")
return emptyList()
}
}
/**
@@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
@@ -15,7 +16,7 @@ import kotlin.random.Random
* Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns
*/
class MessageHandler(private val myPeerID: String) {
class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) {
companion object {
private const val TAG = "MessageHandler"
@@ -110,6 +111,35 @@ class MessageHandler(private val myPeerID: String) {
}
}
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
// Handle encrypted file transfer; generate unique message ID
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
if (file != null) {
Log.d(TAG, "🔓 Decrypted encrypted file from $peerID: name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}'")
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = uniqueMsgId,
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = java.util.Date(packet.timestamp.toLong()),
isRelay = false,
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID
)
Log.d(TAG, "📄 Saved encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
delegate?.onMessageReceived(message)
// Send delivery ACK with generated message ID
sendDeliveryAck(uniqueMsgId, peerID)
} else {
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS
val messageID = String(noisePayload.data, Charsets.UTF_8)
@@ -338,16 +368,37 @@ class MessageHandler(private val myPeerID: String) {
}
try {
// Parse message
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
Log.d(TAG, "📥 FILE_TRANSFER decode success (broadcast): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
}
// Fallback: plain text
val message = BitchatMessage(
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
}
@@ -364,7 +415,32 @@ class MessageHandler(private val myPeerID: String) {
return
}
// Parse message
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
Log.d(TAG, "📥 FILE_TRANSFER decode success (private): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
}
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong()),
isPrivate = true,
recipientNickname = delegate?.getMyNickname()
)
Log.d(TAG, "📄 Saved incoming file to $savedPath")
delegate?.onMessageReceived(message)
return
} else if (isFileTransfer) {
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
}
// Fallback: plain text
val message = BitchatMessage(
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
@@ -377,6 +453,8 @@ class MessageHandler(private val myPeerID: String) {
Log.e(TAG, "Failed to process private message from $peerID: ${e.message}")
}
}
/**
* Handle leave message
@@ -144,6 +144,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@@ -153,6 +154,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
validPacket = false
Log.w(TAG, "Unknown message type: ${packet.type}")
@@ -165,7 +165,7 @@ class StoreForwardManager {
// Send with delays to avoid overwhelming the connection
messagesToSend.forEachIndexed { index, storedMessage ->
delay(index * 100L) // 100ms between messages
delay(index * 10L) // 10ms between messages
delegate?.sendPacket(storedMessage.packet)
}
@@ -0,0 +1,30 @@
package com.bitchat.android.mesh
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
data class TransferProgressEvent(
val transferId: String,
val sent: Int,
val total: Int,
val completed: Boolean
)
object TransferProgressManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _events = MutableSharedFlow<TransferProgressEvent>(replay = 0, extraBufferCapacity = 32)
val events: SharedFlow<TransferProgressEvent> = _events
fun start(id: String, total: Int) { emit(id, 0, total, false) }
fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) }
fun complete(id: String, total: Int) { emit(id, total, total, true) }
private fun emit(id: String, sent: Int, total: Int, done: Boolean) {
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) }
}
}
@@ -0,0 +1,141 @@
package com.bitchat.android.model
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh.
* TLVs:
* - 0x01: filename (UTF-8)
* - 0x02: file size (8 bytes, UInt64)
* - 0x03: mime type (UTF-8)
* - 0x04: content (bytes) — may appear multiple times for large files
*
* Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs.
* For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each.
*
* Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this
* TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes.
* Transport-level fragmentation then splits the final packet for BLE MTU.
*/
data class BitchatFilePacket(
val fileName: String,
val fileSize: Long,
val mimeType: String,
val content: ByteArray
) {
private enum class TLVType(val v: UByte) {
FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u);
companion object { fun from(value: UByte) = values().find { it.v == value } }
}
fun encode(): ByteArray? {
try {
android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType")
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
// Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked.
if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) {
android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)")
return null
}
if (content.size > 0xFFFF) {
android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs")
} else {
android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}")
}
val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes)
val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested
// Compute capacity: header TLVs + single CONTENT TLV with 4-byte length
val contentTLVBytes = 1 + contentLenFieldLen + content.size
val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes
val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN)
// FILE_NAME
buf.put(TLVType.FILE_NAME.v.toByte())
buf.putShort(nameBytes.size.toShort())
buf.put(nameBytes)
// FILE_SIZE (4 bytes)
buf.put(TLVType.FILE_SIZE.v.toByte())
buf.putShort(sizeFieldLen.toShort())
buf.putInt(fileSize.toInt())
// MIME_TYPE
buf.put(TLVType.MIME_TYPE.v.toByte())
buf.putShort(mimeBytes.size.toShort())
buf.put(mimeBytes)
// CONTENT (single TLV with 4-byte length)
buf.put(TLVType.CONTENT.v.toByte())
buf.putInt(content.size)
buf.put(content)
val result = buf.array()
android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total")
return result
} catch (e: Exception) {
android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e)
return null
}
}
companion object {
fun decode(data: ByteArray): BitchatFilePacket? {
android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes")
try {
var off = 0
var name: String? = null
var size: Long? = null
var mime: String? = null
var contentBytes: ByteArray? = null
while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length)
val t = TLVType.from(data[off].toUByte()) ?: return null
off += 1
// CONTENT uses 4-byte length; others use 2-byte length
val len: Int
if (t == TLVType.CONTENT) {
if (off + 4 > data.size) return null
len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF)
off += 4
} else {
if (off + 2 > data.size) return null
len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF)
off += 2
}
if (len < 0 || off + len > data.size) return null
val value = data.copyOfRange(off, off + len)
off += len
when (t) {
TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8)
TLVType.FILE_SIZE -> {
if (len != 4) return null
val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
size = bb.int.toLong()
}
TLVType.MIME_TYPE -> mime = String(value, Charsets.UTF_8)
TLVType.CONTENT -> {
// Expect a single CONTENT TLV
if (contentBytes == null) contentBytes = value else {
// If multiple CONTENT TLVs appear, concatenate for tolerance
contentBytes = (contentBytes!! + value)
}
}
}
}
val n = name ?: return null
val c = contentBytes ?: return null
val s = size ?: c.size.toLong()
val m = mime ?: "application/octet-stream"
val result = BitchatFilePacket(n, s, m, c)
android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes")
return result
} catch (e: Exception) {
android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e)
return null
}
}
}
}
@@ -7,6 +7,14 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*
@Parcelize
enum class BitchatMessageType : Parcelable {
Message,
Audio,
Image,
File
}
/**
* Delivery status for messages - exact same as iOS version
*/
@@ -49,6 +57,7 @@ data class BitchatMessage(
val id: String = UUID.randomUUID().toString().uppercase(),
val sender: String,
val content: String,
val type: BitchatMessageType = BitchatMessageType.Message,
val timestamp: Date,
val isRelay: Boolean = false,
val originalSender: String? = null,
@@ -279,6 +288,7 @@ data class BitchatMessage(
id = id,
sender = sender,
content = content,
type = BitchatMessageType.Message,
timestamp = timestamp,
isRelay = isRelay,
originalSender = originalSender,
@@ -306,6 +316,7 @@ data class BitchatMessage(
if (id != other.id) return false
if (sender != other.sender) return false
if (content != other.content) return false
if (type != other.type) return false
if (timestamp != other.timestamp) return false
if (isRelay != other.isRelay) return false
if (originalSender != other.originalSender) return false
@@ -328,6 +339,7 @@ data class BitchatMessage(
var result = id.hashCode()
result = 31 * result + sender.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + isRelay.hashCode()
result = 31 * result + (originalSender?.hashCode() ?: 0)
@@ -0,0 +1,92 @@
package com.bitchat.android.model
import android.content.Context
import android.net.Uri
import android.util.Log
import com.bitchat.android.features.file.FileUtils
import java.io.File
/**
* Business logic for file sharing operations
*/
object FileSharingManager {
private const val TAG = "FileSharingManager"
/**
* Create a file packet from URI for sending
*/
fun createFilePacketFromUri(
context: Context,
uri: Uri,
originalName: String? = null
): BitchatFilePacket? {
return try {
// Get file name from URI or use original name
val fileName = originalName ?: getFileNameFromUri(context, uri) ?: "unknown_file"
// Copy file to our temp storage for sending
val localPath = FileUtils.copyFileForSending(context, uri) ?: return null
// Determine MIME type
val mimeType = FileUtils.getMimeTypeFromExtension(fileName)
// Read file content
val file = File(localPath)
val content = file.readBytes()
val fileSize = file.length()
// Clean up temp file
file.delete()
val packet = BitchatFilePacket(
fileName = fileName,
fileSize = fileSize,
mimeType = mimeType,
content = content
)
Log.d(TAG, "Created file packet: name=$fileName, size=${FileUtils.formatFileSize(fileSize)}, mime=$mimeType")
packet
} catch (e: Exception) {
Log.e(TAG, "Failed to create file packet from URI", e)
null
}
}
/**
* Extract filename from URI
*/
private fun getFileNameFromUri(context: Context, uri: Uri): String? {
return try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
} ?: uri.lastPathSegment
} catch (e: Exception) {
Log.w(TAG, "Failed to get filename from URI", e)
uri.lastPathSegment
}
}
/**
* Process a received file packet and return file info
*/
data class ReceivedFileInfo(
val fileName: String,
val fileSize: Long,
val mimeType: String,
val content: ByteArray
)
fun processReceivedFile(packet: BitchatFilePacket): ReceivedFileInfo {
return ReceivedFileInfo(
fileName = packet.fileName,
fileSize = packet.fileSize,
mimeType = packet.mimeType,
content = packet.content
)
}
}
@@ -20,7 +20,9 @@ import kotlinx.parcelize.Parcelize
enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u); // Message was delivered
DELIVERED(0x03u), // Message was delivered
FILE_TRANSFER(0x20u);
companion object {
fun fromValue(value: UByte): NoisePayloadType? {
@@ -9,5 +9,6 @@ import com.bitchat.android.protocol.BitchatPacket
data class RoutedPacket(
val packet: BitchatPacket,
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
val relayAddress: String? = null // Address it came from (for avoiding loopback)
)
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null // Optional stable transfer ID for progress tracking
)
@@ -202,6 +202,8 @@ object TorManager {
lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR)
@@ -160,6 +160,31 @@ class NostrDirectMessageHandler(
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted file transfer
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
val message = BitchatMessage(
id = uniqueMsgId,
sender = senderNickname,
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
}
} else {
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
}
}
}
}
@@ -240,11 +240,7 @@ class NostrTransport(
return@launch
}
val content = if (isFavorite) {
"[FAVORITED]:${senderIdentity.npub}"
} else {
"[UNFAVORITED]:${senderIdentity.npub}"
}
val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}"
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
@@ -239,6 +240,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
PermissionType.OTHER -> Icons.Filled.Settings
@@ -78,6 +78,7 @@ class PermissionManager(private val context: Context) {
*/
fun getOptionalPermissions(): List<String> {
val optional = mutableListOf<String>()
// Notifications on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
optional.add(Manifest.permission.POST_NOTIFICATIONS)
}
@@ -189,6 +190,8 @@ class PermissionManager(private val context: Context) {
)
}
// Microphone category removed from onboarding
// Battery optimization category (if applicable)
if (isBatteryOptimizationSupported()) {
categories.add(
@@ -257,6 +260,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
BATTERY_OPTIMIZATION("Battery Optimization"),
OTHER("Other")
@@ -16,7 +16,8 @@ enum class MessageType(val value: UByte) {
NOISE_HANDSHAKE(0x10u), // Noise handshake
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets
REQUEST_SYNC(0x21u); // GCS-based sync request
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
companion object {
fun fromValue(value: UByte): MessageType? {
@@ -33,15 +34,15 @@ object SpecialRecipients {
}
/**
* Binary packet format - 100% compatible with iOS version
*
* Header (Fixed 13 bytes):
* Binary packet format - 100% backward compatible with iOS version
*
* Header (13 bytes for v1, 15 bytes for v2):
* - Version: 1 byte
* - Type: 1 byte
* - Type: 1 byte
* - TTL: 1 byte
* - Timestamp: 8 bytes (UInt64, big-endian)
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed)
* - PayloadLength: 2 bytes (UInt16, big-endian)
* - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian)
*
* Variable sections:
* - SenderID: 8 bytes (fixed)
@@ -166,19 +167,27 @@ data class BitchatPacket(
}
/**
* Binary Protocol implementation - exact same format as iOS version
* Binary Protocol implementation - supports v1 and v2, backward compatible
*/
object BinaryProtocol {
private const val HEADER_SIZE = 13
private const val HEADER_SIZE_V1 = 13
private const val HEADER_SIZE_V2 = 15
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
object Flags {
const val HAS_RECIPIENT: UByte = 0x01u
const val HAS_SIGNATURE: UByte = 0x02u
const val IS_COMPRESSED: UByte = 0x04u
}
private fun getHeaderSize(version: UByte): Int {
return when (version) {
1u.toUByte() -> HEADER_SIZE_V1
else -> HEADER_SIZE_V2 // v2+ will use 4-byte payload length
}
}
fun encode(packet: BitchatPacket): ByteArray? {
try {
@@ -195,7 +204,13 @@ object BinaryProtocol {
}
}
val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) }
// Compute a safe capacity for the unpadded frame
val headerSize = getHeaderSize(packet.version)
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
val payloadBytes = payload.size + if (isCompressed) 2 else 0
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
buffer.put(packet.version.toByte())
@@ -218,9 +233,13 @@ object BinaryProtocol {
}
buffer.put(flags.toByte())
// Payload length (2 bytes, big-endian) - includes original size if compressed
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
buffer.putShort(payloadDataSize.toShort())
if (packet.version >= 2u.toUByte()) {
buffer.putInt(payloadDataSize) // 4 bytes for v2+
} else {
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
}
// SenderID (exactly 8 bytes)
val senderBytes = packet.senderID.take(SENDER_ID_SIZE).toByteArray()
@@ -284,34 +303,40 @@ object BinaryProtocol {
*/
private fun decodeCore(raw: ByteArray): BitchatPacket? {
try {
if (raw.size < HEADER_SIZE + SENDER_ID_SIZE) return null
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
val buffer = ByteBuffer.wrap(raw).apply { order(ByteOrder.BIG_ENDIAN) }
// Header
val version = buffer.get().toUByte()
if (version != 1u.toUByte()) return null
if (version.toUInt() != 1u && version.toUInt() != 2u) return null // Support v1 and v2
val headerSize = getHeaderSize(version)
val type = buffer.get().toUByte()
val ttl = buffer.get().toUByte()
// Timestamp
val timestamp = buffer.getLong().toULong()
// Flags
val flags = buffer.get().toUByte()
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
// Payload length
val payloadLength = buffer.getShort().toUShort()
// Payload length - version-dependent (2 or 4 bytes)
val payloadLength = if (version >= 2u.toUByte()) {
buffer.getInt().toUInt() // 4 bytes for v2+
} else {
buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt
}
// Calculate expected total size
var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt()
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
if (hasSignature) expectedSize += SIGNATURE_SIZE
if (raw.size < expectedSize) return null
// SenderID
@@ -114,9 +114,7 @@ class MessageRouter private constructor(
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
val myNpub = try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
} catch (_: Exception) { null }
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
mesh.sendPrivateMessage(content, toPeerID, nickname)
@@ -1,4 +1,8 @@
package com.bitchat.android.ui
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
import androidx.compose.animation.*
import androidx.compose.animation.core.*
@@ -21,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer
/**
* Main ChatScreen - REFACTORED to use component-based architecture
@@ -60,6 +65,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
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) }
@@ -154,28 +162,53 @@ fun ChatScreen(viewModel: ChatViewModel) {
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
}
)
// Input area - stays at bottom
ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
}
},
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
// Bridge file share from lower-level input to ViewModel
androidx.compose.runtime.LaunchedEffect(Unit) {
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
}
}
ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
}
},
onSendVoiceNote = { peer, onionOrChannel, path ->
viewModel.sendVoiceNote(peer, onionOrChannel, path)
},
onSendImageNote = { peer, onionOrChannel, path ->
viewModel.sendImageNote(peer, onionOrChannel, path)
},
onSendFileNote = { peer, onionOrChannel, path ->
viewModel.sendFileNote(peer, onionOrChannel, path)
},
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
val commandText = viewModel.selectCommandSuggestion(suggestion)
messageText = TextFieldValue(
text = commandText,
@@ -288,6 +321,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
// 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
ChatDialogs(
showPasswordDialog = showPasswordDialog,
@@ -327,6 +369,9 @@ private fun ChatInputSection(
messageText: TextFieldValue,
onMessageTextChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onSendVoiceNote: (String?, String?, String) -> Unit,
onSendImageNote: (String?, String?, String) -> Unit,
onSendFileNote: (String?, String?, String) -> Unit,
showCommandSuggestions: Boolean,
commandSuggestions: List<CommandSuggestion>,
showMentionSuggestions: Boolean,
@@ -351,10 +396,8 @@ private fun ChatInputSection(
onSuggestionClick = onCommandSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
}
// Mention suggestions box
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
MentionSuggestionsBox(
@@ -362,14 +405,15 @@ private fun ChatInputSection(
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
}
MessageInput(
value = messageText,
onValueChange = onMessageTextChange,
onSend = onSend,
onSendVoiceNote = onSendVoiceNote,
onSendImageNote = onSendImageNote,
onSendFileNote = onSendFileNote,
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
@@ -378,7 +422,6 @@ private fun ChatInputSection(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatFloatingHeader(
@@ -156,6 +156,105 @@ fun formatMessageAsAnnotatedString(
return builder.toAnnotatedString()
}
/**
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
*/
fun formatMessageHeaderAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") {
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
val (baseName, suffix) = splitSuffix(message.sender)
// "<@"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("<@")
builder.pop()
// Base name (clickable when not self)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
val nicknameStart = builder.length
builder.append(truncateNickname(baseName))
val nicknameEnd = builder.length
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
// Hashtag suffix
if (suffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor.copy(alpha = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append(suffix)
builder.pop()
}
// Sender suffix ">"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append(">")
builder.pop()
// Timestamp and optional PoW bits, matching normal message appearance
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
message.powDifficulty?.let { bits ->
if (bits > 0) builder.append("${bits}b")
}
builder.pop()
} else {
// System message header (should rarely apply to voice)
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.5f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* iOS-style peer color assignment using djb2 hash algorithm
* Avoids orange (~30°) reserved for self messages
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.protocol.BitchatPacket
@@ -33,21 +34,37 @@ class ChatViewModel(
private const val TAG = "ChatViewModel"
}
// State management
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
}
// MARK: - State management
private val state = ChatState()
// Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>()
private val messageTransferMap = mutableMapOf<String, String>()
// Specialized managers
private val dataManager = DataManager(application.applicationContext)
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
override fun getMyPeerID(): String = meshService.myPeerID
}
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
private val notificationManager = NotificationManager(
@@ -55,6 +72,9 @@ class ChatViewModel(
NotificationManagerCompat.from(application.applicationContext),
NotificationIntervalManager()
)
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -121,6 +141,27 @@ class ChatViewModel(
init {
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
viewModelScope.launch {
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
mediaSendingManager.handleTransferProgressEvent(evt)
}
}
}
fun cancelMediaSend(messageId: String) {
val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] }
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(messageId)
}
}
}
}
private fun loadAndInitialize() {
@@ -199,6 +240,8 @@ class ChatViewModel(
messageManager.addMessage(welcomeMessage)
}
}
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
}
override fun onCleared() {
@@ -1,4 +1,6 @@
package com.bitchat.android.ui
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -16,7 +18,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -24,7 +25,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
@@ -33,9 +33,16 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.foundation.isSystemInDarkTheme
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
import com.bitchat.android.ui.media.ImagePickerButton
import com.bitchat.android.ui.media.FilePickerButton
/**
* Input components for ChatScreen
@@ -157,6 +164,9 @@ fun MessageInput(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onSendVoiceNote: (String?, String?, String) -> Unit,
onSendImageNote: (String?, String?, String) -> Unit,
onSendFileNote: (String?, String?, String) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
@@ -165,16 +175,22 @@ fun MessageInput(
val colorScheme = MaterialTheme.colorScheme
val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank() // Check if there's text for send button state
val keyboard = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Text input with placeholder
// Text input with placeholder OR visualizer when recording
Box(
modifier = Modifier.weight(1f)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
@@ -182,7 +198,7 @@ fun MessageInput(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(colorScheme.primary),
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend() // Only send if there's text
@@ -192,13 +208,14 @@ fun MessageInput(
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
)
// Show placeholder when there's no text
if (value.text.isEmpty()) {
// Show placeholder when there's no text and not recording
if (value.text.isEmpty() && !isRecording) {
Text(
text = "type a message...",
style = MaterialTheme.typography.bodyMedium.copy(
@@ -208,23 +225,94 @@ fun MessageInput(
modifier = Modifier.fillMaxWidth()
)
}
// Overlay the real-time scrolling waveform while recording
if (isRecording) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(32.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(20.dp))
val secs = (elapsedMs / 1000).toInt()
val mm = secs / 60
val ss = secs % 60
val maxSecs = 10 // 10 second max recording time
val maxMm = maxSecs / 60
val maxSs = maxSecs % 60
Text(
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
fontFamily = FontFamily.Monospace,
color = colorScheme.primary,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
}
}
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Command quick access button
// Voice and image buttons when no text (always visible for mesh + channels + private)
if (value.text.isEmpty()) {
FilledTonalIconButton(
onClick = {
onValueChange(TextFieldValue(text = "/", selection = TextRange("/".length)))
},
modifier = Modifier.size(32.dp)
) {
Text(
text = "/",
textAlign = TextAlign.Center
)
// Hold-to-record microphone
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
// Ensure latest values are used when finishing recording
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel)
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
// Image button (image picker) - hide during recording
if (!isRecording) {
// Revert to original separate buttons: round File button (left) and the old Image plus button (right)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
// DISABLE FILE PICKER
//FilePickerButton(
// onFileReady = { path ->
// onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
// }
//)
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
}
)
}
}
Spacer(Modifier.width(1.dp))
VoiceRecordButton(
backgroundColor = bg,
onStart = {
isRecording = true
elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not force-show keyboard
if (isFocused.value) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
},
onAmplitude = { amp, ms ->
amplitude = amp
elapsedMs = ms
},
onFinish = { path ->
isRecording = false
// Extract and cache waveform from the actual audio file to match receiver rendering
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
if (arr != null) {
try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {}
}
}
// BLE path (private or public) — use latest values to avoid stale captures
latestOnSendVoiceNote.value(
latestSelectedPeer.value,
latestChannel.value,
path
)
}
)
} else {
// Send button with enabled/disabled state
IconButton(
@@ -272,6 +360,8 @@ fun MessageInput(
}
}
}
// Auto-stop handled inside VoiceRecordButton
}
@Composable
@@ -74,12 +74,14 @@ object PoWMiningTracker {
@Composable
fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage,
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
val isAnimating = shouldAnimateMessage(message.id)
@@ -0,0 +1,329 @@
package com.bitchat.android.ui
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import java.util.Date
import java.security.MessageDigest
/**
* Handles media file sending operations (voice notes, images, generic files)
* Separated from ChatViewModel for better separation of concerns
*/
class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val meshService: BluetoothMeshService
) {
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB limit
}
// Track in-flight transfer progress: transferId -> messageId and reverse
private val transferMessageMap = mutableMapOf<String, String>()
private val messageTransferMap = mutableMapOf<String, String>()
/**
* Send a voice note (audio file)
*/
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
} else {
sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Audio)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send voice note: ${e.message}")
}
}
/**
* Send an image file
*/
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
Log.d(TAG, "🔄 Starting image send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "image/jpeg",
content = file.readBytes()
)
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
} else {
sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Image)
}
} catch (e: Exception) {
Log.e(TAG, "❌ CRITICAL: Image send failed completely", e)
Log.e(TAG, "❌ Image path: $filePath")
Log.e(TAG, "❌ Error details: ${e.message}")
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
}
}
/**
* Send a generic file
*/
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
Log.d(TAG, "🔄 Starting file send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
// Use the real MIME type based on extension; fallback to octet-stream
val mimeType = try {
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
} catch (_: Exception) {
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
// Try to preserve the original file name if our copier prefixed it earlier
val originalName = run {
val name = file.name
val base = name.substringBeforeLast('.')
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
stripped + ext
}
Log.d(TAG, "📝 Original filename: $originalName")
val filePacket = BitchatFilePacket(
fileName = originalName,
fileSize = file.length(),
mimeType = mimeType,
content = file.readBytes()
)
Log.d(TAG, "📦 Created file packet successfully")
val messageType = when {
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
else -> BitchatMessageType.File
}
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, messageType)
} else {
sendPublicFile(channelOrNull, filePacket, filePath, messageType)
}
} catch (e: Exception) {
Log.e(TAG, "❌ CRITICAL: File send failed completely", e)
Log.e(TAG, "❌ File path: $filePath")
Log.e(TAG, "❌ Error details: ${e.message}")
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
}
}
/**
* Send a file privately (encrypted)
*/
private fun sendPrivateFile(
toPeerID: String,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return
}
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}")
val msg = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
sender = state.getNicknameValue() ?: "me",
content = filePath,
type = messageType,
timestamp = Date(),
isRelay = false,
isPrivate = true,
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
senderPeerID = meshService.myPeerID
)
messageManager.addPrivateMessage(toPeerID, msg)
synchronized(transferMessageMap) {
transferMessageMap[transferId] = msg.id
messageTransferMap[msg.id] = transferId
}
// Seed progress so delivery icons render for media
messageManager.updateMessageDeliveryStatus(
msg.id,
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
)
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
meshService.sendFilePrivate(toPeerID, filePacket)
Log.d(TAG, "✅ File send completed successfully")
}
/**
* Send a file publicly (broadcast or channel)
*/
private fun sendPublicFile(
channelOrNull: String?,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return
}
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}")
val message = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
sender = state.getNicknameValue() ?: meshService.myPeerID,
content = filePath,
type = messageType,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
channel = channelOrNull
)
if (!channelOrNull.isNullOrBlank()) {
channelManager.addChannelMessage(channelOrNull, message, meshService.myPeerID)
} else {
messageManager.addMessage(message)
}
synchronized(transferMessageMap) {
transferMessageMap[transferId] = message.id
messageTransferMap[message.id] = transferId
}
// Seed progress so animations start immediately
messageManager.updateMessageDeliveryStatus(
message.id,
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
)
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket)
Log.d(TAG, "✅ File broadcast completed successfully")
}
/**
* Cancel a media transfer by message ID
*/
fun cancelMediaSend(messageId: String) {
val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] }
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(messageId)
}
}
}
}
/**
* Update progress for a transfer
*/
fun updateTransferProgress(transferId: String, messageId: String) {
synchronized(transferMessageMap) {
transferMessageMap[transferId] = messageId
messageTransferMap[messageId] = transferId
}
}
/**
* Handle transfer progress events
*/
fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) {
val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] }
if (msgId != null) {
if (evt.completed) {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())
)
synchronized(transferMessageMap) {
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
}
} else {
messageManager.updateMessageDeliveryStatus(
msgId,
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(evt.sent, evt.total)
)
}
}
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
bytes.size.toString(16)
}
}
@@ -1,6 +1,7 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
@@ -55,10 +56,11 @@ class MeshDelegateHandler(
message.senderPeerID?.let { senderPeerID ->
// Use nickname if available, fall back to sender or senderPeerID
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
notificationManager.showPrivateMessageNotification(
senderPeerID = senderPeerID,
senderNickname = senderNickname,
messageContent = message.content
senderPeerID = senderPeerID,
senderNickname = senderNickname,
messageContent = preview
)
}
} else if (message.channel != null) {
@@ -285,4 +287,5 @@ class MeshDelegateHandler(
fun getPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? {
return getMeshService().getPeerInfo(peerID)
}
}
@@ -1,6 +1,7 @@
package com.bitchat.android.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -15,6 +16,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -30,6 +34,17 @@ import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import java.text.SimpleDateFormat
import java.util.*
import com.bitchat.android.ui.media.VoiceNotePlayer
import androidx.compose.material3.Icon
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.shape.CircleShape
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.model.BitchatMessageType
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
/**
* Message display components for ChatScreen
@@ -45,7 +60,9 @@ fun MessagesList(
forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
) {
val listState = rememberLazyListState()
@@ -97,13 +114,19 @@ fun MessagesList(
modifier = modifier,
reverseLayout = true
) {
items(messages.asReversed()) { message ->
items(
items = messages.asReversed(),
key = { it.id }
) { message ->
MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick
)
}
}
@@ -115,8 +138,11 @@ fun MessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
messages: List<BitchatMessage> = emptyList(),
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
@@ -125,27 +151,42 @@ fun MessageItem(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Create a custom layout that combines selectable text with clickable nickname areas
MessageTextWithClickableNicknames(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = Modifier.weight(1f)
)
// Delivery status for private messages
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Top
) {
// Provide a small end padding for own private messages so overlay doesn't cover text
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
// Create a custom layout that combines selectable text with clickable nickname areas
MessageTextWithClickableNicknames(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick,
modifier = Modifier
.weight(1f)
.padding(end = endPad)
)
}
// Delivery status for private messages (overlay, non-displacing)
if (message.isPrivate && message.sender == currentUserNickname) {
message.deliveryStatus?.let { status ->
DeliveryStatusIcon(status = status)
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 2.dp)
) {
DeliveryStatusIcon(status = status)
}
}
}
}
@@ -156,16 +197,155 @@ fun MessageItem(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageTextWithClickableNicknames(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
private fun MessageTextWithClickableNicknames(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
// Image special rendering
if (message.type == BitchatMessageType.Image) {
com.bitchat.android.ui.media.ImageMessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick,
modifier = modifier
)
return
}
// Voice note special rendering
if (message.type == BitchatMessageType.Audio) {
com.bitchat.android.ui.media.AudioMessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
modifier = modifier
)
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()) {
// Header: nickname + timestamp line above the file, identical styling to text messages
val headerText = formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = headerText,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
)
// 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 = 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(Color.Gray.copy(alpha = 0.6f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
}
}
} else {
Text(text = "[file unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
}
return
}
// Check if this message should be animated during PoW mining
val shouldAnimate = shouldAnimateMessage(message.id)
@@ -174,12 +354,14 @@ private fun MessageTextWithClickableNicknames(
// Display message with matrix animation for content
MessageWithMatrixAnimation(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onImageClick = onImageClick,
modifier = modifier
)
} else {
@@ -326,8 +508,9 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
)
}
is DeliveryStatus.PartiallyDelivered -> {
// Show a single subdued check without numeric label
Text(
text = "${status.reached}/${status.total}",
text = "",
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -244,6 +244,49 @@ class MessageManager(private val state: ChatState) {
}
state.setChannelMessages(updatedChannelMessages)
}
// Remove a message from all locations (main timeline, private chats, channels)
fun removeMessageById(messageID: String) {
// Main timeline
run {
val list = state.getMessagesValue().toMutableList()
val idx = list.indexOfFirst { it.id == messageID }
if (idx >= 0) {
list.removeAt(idx)
state.setMessages(list)
}
}
// Private chats
run {
val chats = state.getPrivateChatsValue().toMutableMap()
var changed = false
chats.keys.toList().forEach { key ->
val msgs = chats[key]?.toMutableList() ?: mutableListOf()
val idx = msgs.indexOfFirst { it.id == messageID }
if (idx >= 0) {
msgs.removeAt(idx)
chats[key] = msgs
changed = true
}
}
if (changed) state.setPrivateChats(chats)
}
// Channels
run {
val chans = state.getChannelMessagesValue().toMutableMap()
var changed = false
chans.keys.toList().forEach { ch ->
val msgs = chans[ch]?.toMutableList() ?: mutableListOf()
val idx = msgs.indexOfFirst { it.id == messageID }
if (idx >= 0) {
msgs.removeAt(idx)
chans[ch] = msgs
changed = true
}
}
if (changed) state.setChannelMessages(chans)
}
}
// MARK: - Utility Functions
@@ -0,0 +1,48 @@
package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
/**
* Utilities for building human-friendly notification text/previews.
*/
object NotificationTextUtils {
/**
* Build a user-friendly notification preview for private messages, especially attachments.
* Examples:
* - Image: "📷 sent an image"
* - Audio: "🎤 sent a voice message"
* - File (pdf): "📄 file.pdf"
* - Text: original message content
*/
fun buildPrivateMessagePreview(message: BitchatMessage): String {
return try {
when (message.type) {
BitchatMessageType.Image -> "📷 sent an image"
BitchatMessageType.Audio -> "🎤 sent a voice message"
BitchatMessageType.File -> {
// Show just the filename (not the full path)
val name = try { java.io.File(message.content).name } catch (_: Exception) { null }
if (!name.isNullOrBlank()) {
val lower = name.lowercase()
val icon = when {
lower.endsWith(".pdf") -> "📄"
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".7z") -> "🗜️"
lower.endsWith(".doc") || lower.endsWith(".docx") -> "📄"
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> "📊"
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> "📈"
else -> "📎"
}
"$icon $name"
} else {
"📎 sent a file"
}
}
else -> message.content
}
} catch (_: Exception) {
// Fallback to original content on any error
message.content
}
}
}
@@ -0,0 +1,137 @@
package com.bitchat.android.ui
import android.Manifest
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.voice.VoiceRecorder
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordButton(
modifier: Modifier = Modifier,
backgroundColor: Color,
onStart: () -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
var isRecording by remember { mutableStateOf(false) }
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(null) }
var recordingStart by remember { mutableStateOf(0L) }
val scope = rememberCoroutineScope()
var ampJob by remember { mutableStateOf<Job?>(null) }
// Ensure latest callbacks are used inside gesture coroutine
val latestOnStart = rememberUpdatedState(onStart)
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
val latestOnFinish = rememberUpdatedState(onFinish)
Box(
modifier = modifier
.size(32.dp)
.background(backgroundColor, CircleShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (!isRecording) {
if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest()
return@detectTapGestures
}
val rec = VoiceRecorder(context)
val f = rec.start()
recorder = rec
isRecording = f != null
recordedFilePath = f?.absolutePath
recordingStart = System.currentTimeMillis()
if (isRecording) {
latestOnStart.value()
// Haptic "knock" when recording starts
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
// Start amplitude polling loop
ampJob?.cancel()
ampJob = scope.launch {
while (isActive && isRecording) {
val amp = recorder?.pollAmplitude() ?: 0
val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsedMs)
// Auto-stop after 10 seconds
if (elapsedMs >= 10_000 && isRecording) {
val file = recorder?.stop()
isRecording = false
recorder = null
val path = file?.absolutePath
if (!path.isNullOrBlank()) {
// Haptic "knock" on auto stop
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
latestOnFinish.value(path)
}
break
}
delay(80)
}
}
}
}
try {
awaitRelease()
} finally {
if (isRecording) {
// Extend recording for 500ms after release to avoid clipping
delay(500)
}
if (isRecording) {
val file = recorder?.stop()
isRecording = false
recorder = null
val path = (file?.absolutePath ?: recordedFilePath)
recordedFilePath = null
if (!path.isNullOrBlank()) {
// Haptic "knock" when recording stops
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
latestOnFinish.value(path)
}
}
ampJob?.cancel()
ampJob = null
}
}
)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "Record voice note",
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
}
}
@@ -0,0 +1,17 @@
package com.bitchat.android.ui.events
/**
* Lightweight dispatcher so lower-level UI (MessageInput) can trigger
* file sending without holding a direct reference to ChatViewModel.
*/
object FileShareDispatcher {
@Volatile private var handler: ((String?, String?, String) -> Unit)? = null
fun setHandler(h: ((String?, String?, String) -> Unit)?) {
handler = h
}
fun dispatch(peerIdOrNull: String?, channelOrNull: String?, path: String) {
handler?.invoke(peerIdOrNull, channelOrNull, path)
}
}
@@ -0,0 +1,99 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
@Composable
fun AudioMessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
) {
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
}
Column(modifier = modifier.fillMaxWidth()) {
// Header: nickname + timestamp line above the audio note, identical styling to text messages
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = headerText,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
)
Row(verticalAlignment = Alignment.CenterVertically) {
VoiceNotePlayer(
path = path,
progressOverride = overrideProgress,
progressColor = overrideColor
)
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 = "Cancel", tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
}
@@ -0,0 +1,95 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
/**
* Draws an image progressively, revealing it block-by-block based on progress [0f..1f].
* blocksX * blocksY defines the grid density; higher numbers look more "modem-era".
*/
@Composable
fun BlockRevealImage(
bitmap: ImageBitmap,
progress: Float,
blocksX: Int = 24,
blocksY: Int = 16,
modifier: Modifier = Modifier
) {
val frac = progress.coerceIn(0f, 1f)
Canvas(modifier = modifier.fillMaxWidth()) {
drawProgressive(bitmap, frac, blocksX, blocksY)
}
}
private fun DrawScope.drawProgressive(
bitmap: ImageBitmap,
progress: Float,
blocksX: Int,
blocksY: Int
) {
val canvasW = size.width
val canvasH = size.height
if (canvasW <= 0f || canvasH <= 0f) return
val totalBlocks = (blocksX * blocksY).coerceAtLeast(1)
val toShow = (totalBlocks * progress).toInt().coerceIn(0, totalBlocks)
if (toShow <= 0) return
val imgW = bitmap.width
val imgH = bitmap.height
if (imgW <= 0 || imgH <= 0) return
// Compute scaled destination rect maintaining aspect fit
val canvasRatio = canvasW / canvasH
val imageRatio = imgW.toFloat() / imgH.toFloat()
val dstW: Float
val dstH: Float
if (imageRatio >= canvasRatio) {
dstW = canvasW
dstH = canvasW / imageRatio
} else {
dstH = canvasH
dstW = canvasH * imageRatio
}
val left = 0f
val top = (canvasH - dstH) / 2f
// Precompute integer edges to avoid 1px gaps due to rounding
val xDstEdges = IntArray(blocksX + 1) { i -> (left + (dstW * i / blocksX)).toInt().coerceAtLeast(0) }
val yDstEdges = IntArray(blocksY + 1) { i -> (top + (dstH * i / blocksY)).toInt().coerceAtLeast(0) }
val xSrcEdges = IntArray(blocksX + 1) { i -> (imgW * i / blocksX) }
val ySrcEdges = IntArray(blocksY + 1) { i -> (imgH * i / blocksY) }
var shown = 0
outer@ for (by in 0 until blocksY) {
for (bx in 0 until blocksX) {
if (shown >= toShow) break@outer
val sx = xSrcEdges[bx]
val sy = ySrcEdges[by]
val sw = xSrcEdges[bx + 1] - xSrcEdges[bx]
val sh = ySrcEdges[by + 1] - ySrcEdges[by]
val dx = xDstEdges[bx]
val dy = yDstEdges[by]
val dw = xDstEdges[bx + 1] - xDstEdges[bx]
val dh = yDstEdges[by + 1] - yDstEdges[by]
drawImage(
image = bitmap,
srcOffset = IntOffset(sx, sy),
srcSize = IntSize(sw, sh),
dstOffset = IntOffset(dx, dy),
dstSize = IntSize(dw.coerceAtLeast(1), dh.coerceAtLeast(1)),
alpha = 1f
)
shown++
}
}
}
@@ -0,0 +1,154 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.clickable
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.material.icons.Icons
import androidx.compose.material.icons.filled.Description
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
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
*/
@Composable
fun FileMessageItem(
packet: BitchatFilePacket,
onFileClick: () -> Unit,
modifier: Modifier = Modifier
) {
var showDialog by remember { mutableStateOf(false) }
Card(
modifier = modifier
.fillMaxWidth(0.8f)
.clickable { showDialog = true },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
tint = getFileIconColor(packet.fileName),
modifier = Modifier.size(32.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// File name
Text(
text = packet.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// File details
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = FileUtils.formatFileSize(packet.fileSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// File type indicator
FileTypeBadge(mimeType = packet.mimeType)
}
}
}
}
// File viewer dialog
if (showDialog) {
FileViewerDialog(
packet = packet,
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")
}
)
}
}
/**
* Small badge showing file type
*/
@Composable
private fun FileTypeBadge(mimeType: String) {
val (text, color) = when {
mimeType.startsWith("application/pdf") -> "PDF" to Color(0xFFDC2626)
mimeType.startsWith("text/") -> "TXT" to Color(0xFF059669)
mimeType.startsWith("image/") -> "IMG" to Color(0xFF7C3AED)
mimeType.startsWith("audio/") -> "AUD" to Color(0xFFEA580C)
mimeType.startsWith("video/") -> "VID" to Color(0xFF2563EB)
mimeType.contains("document") -> "DOC" to Color(0xFF1D4ED8)
mimeType.contains("zip") || mimeType.contains("rar") -> "ZIP" to Color(0xFF7C2D12)
else -> "FILE" to MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
}
/**
* Get appropriate icon color based on file extension
*/
private fun getFileIconColor(fileName: String): Color {
val extension = fileName.substringAfterLast(".", "").lowercase()
return when (extension) {
"pdf" -> Color(0xFFDC2626) // Red
"doc", "docx" -> Color(0xFF1D4ED8) // Blue
"xls", "xlsx" -> Color(0xFF059669) // Green
"ppt", "pptx" -> Color(0xFFEA580C) // Orange
"txt", "json", "xml" -> Color(0xFF7C3AED) // Purple
"jpg", "png", "gif", "webp" -> Color(0xFF2563EB) // Blue
"mp3", "wav", "m4a" -> Color(0xFFEA580C) // Orange
"mp4", "avi", "mov" -> Color(0xFFDC2626) // Red
"zip", "rar", "7z" -> Color(0xFF7C2D12) // Brown
else -> Color(0xFF6B7280) // Gray
}
}
@@ -0,0 +1,52 @@
package com.bitchat.android.ui.media
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Attachment
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.file.FileUtils
@Composable
fun FilePickerButton(
modifier: Modifier = Modifier,
onFileReady: (String) -> Unit
) {
val context = LocalContext.current
// Use SAF - supports all file types
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
// Persist temporary read permission so we can copy
try { context.contentResolver.takePersistableUriPermission(uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {}
val path = FileUtils.copyFileForSending(context, uri)
if (!path.isNullOrBlank()) onFileReady(path)
}
}
IconButton(
onClick = {
// Allow any MIME type; user asked to choose between image or file at higher level UI
filePicker.launch(arrayOf("*/*"))
},
modifier = modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Filled.Attachment,
contentDescription = "Pick file",
tint = Color.Gray,
modifier = Modifier.size(20.dp).rotate(90f)
)
}
}
@@ -0,0 +1,152 @@
package com.bitchat.android.ui.media
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* Matrix-style file sending animation with character-by-character reveal
* Shows a file icon with filename being "typed" out character by character
* and progress visualization
*/
@Composable
fun FileSendingAnimation(
modifier: Modifier = Modifier,
fileName: String,
progress: Float = 0f
) {
var revealedChars by remember(fileName) { mutableFloatStateOf(0f) }
var showCursor by remember { mutableStateOf(true) }
// Animate character reveal
val animatedChars by animateFloatAsState(
targetValue = revealedChars,
animationSpec = tween(
durationMillis = 50 * fileName.length,
easing = LinearEasing
),
label = "fileNameReveal"
)
// Cursor blinking
LaunchedEffect(Unit) {
while (true) {
delay(500)
showCursor = !showCursor
}
}
// Trigger reveal animation
LaunchedEffect(fileName) {
revealedChars = fileName.length.toFloat()
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
tint = Color(0xFF00C851), // Green like app theme
modifier = Modifier.size(32.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Filename reveal animation (Matrix-style)
Row(verticalAlignment = Alignment.Bottom) {
// Revealed part of filename
val revealedText = fileName.substring(0, animatedChars.toInt())
androidx.compose.material3.Text(
text = revealedText,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color.White
),
modifier = Modifier.padding(end = 2.dp)
)
// Blinking cursor (only if not fully revealed)
if (animatedChars < fileName.length && showCursor) {
androidx.compose.material3.Text(
text = "_",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color.White
)
)
}
}
// Progress visualization
FileProgressBars(
progress = progress,
modifier = Modifier.fillMaxWidth().height(20.dp)
)
}
}
}
/**
* ASCII-style progress bars for file transfer
*/
@Composable
private fun FileProgressBars(
progress: Float,
modifier: Modifier = Modifier
) {
val bars = 12
val filledBars = (progress * bars).toInt()
// Create a matrix-style progress bar string
val progressString = buildString {
append("[")
for (i in 0 until bars) {
append(if (i < filledBars) "" else "")
}
append("] ")
append("${(progress * 100).toInt()}%")
}
androidx.compose.material3.Text(
text = progressString,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color(0xFF00FF7F) // Matrix green
),
modifier = modifier
)
}
@@ -0,0 +1,161 @@
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.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
import kotlinx.coroutines.launch
import java.io.File
/**
* Dialog for handling received file messages in modern chat style
*/
@Composable
fun FileViewerDialog(
packet: BitchatFilePacket,
onDismiss: () -> Unit,
onSaveToDevice: (ByteArray, String) -> 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 = "📎 File Received",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
// File info
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "📄 ${packet.fileName}",
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
Text(
text = "📏 Size: ${FileUtils.formatFileSize(packet.fileSize)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "🏷️ 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("📂 Open / Save")
}
// Dismiss button
Button(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("❌ Close")
}
}
}
}
}
}
/**
* 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
}
}
@@ -0,0 +1,170 @@
package com.bitchat.android.ui.media
import android.content.ContentValues
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.Image
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import java.io.File
/**
* Fullscreen image viewer with swipe navigation between multiple images
* @param imagePaths List of all image file paths in the current chat
* @param initialIndex Starting index of the current image in the list
* @param onClose Callback when the viewer should be dismissed
*/
// Backward compatibility for single image (can be removed after updating all callers)
@Composable
fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
FullScreenImageViewer(listOf(path), 0, onClose)
}
/**
* Fullscreen image viewer with swipe navigation between multiple images
* @param imagePaths List of all image file paths in the current chat
* @param initialIndex Starting index of the current image in the list
* @param onClose Callback when the viewer should be dismissed
*/
@Composable
fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClose: () -> Unit) {
val context = LocalContext.current
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = imagePaths::size)
if (imagePaths.isEmpty()) {
onClose()
return
}
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(color = Color.Black) {
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val currentPath = imagePaths[page]
val bmp = remember(currentPath) { try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null } }
bmp?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = "Image ${page + 1} of ${imagePaths.size}",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Image unavailable", color = Color.White)
}
}
}
// Image counter
if (imagePaths.size > 1) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.align(Alignment.TopCenter)
.background(Color(0x66000000), androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = "${(pagerState.currentPage ?: 0) + 1} / ${imagePaths.size}",
color = Color.White,
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.End
) {
Box(
modifier = Modifier
.size(36.dp)
.background(Color(0x66000000), CircleShape)
.clickable { saveToDownloads(context, imagePaths[pagerState.currentPage].toString()) },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Download, "Save current image", tint = Color.White)
}
Spacer(Modifier.width(12.dp))
Box(
modifier = Modifier
.size(36.dp)
.background(Color(0x66000000), CircleShape)
.clickable { onClose() },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Close, "Close", tint = Color.White)
}
}
}
}
}
}
private fun saveToDownloads(context: android.content.Context, path: String) {
runCatching {
val name = File(path).name
val mime = when {
name.endsWith(".png", true) -> "image/png"
name.endsWith(".webp", true) -> "image/webp"
else -> "image/jpeg"
}
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Downloads.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
context.contentResolver.openOutputStream(uri)?.use { out ->
File(path).inputStream().use { it.copyTo(out) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
context.contentResolver.update(uri, v2, null, null)
}
// Show toast message indicating the image has been saved
Toast.makeText(context, "Image saved to Downloads", Toast.LENGTH_SHORT).show()
}
}.onFailure {
// Optionally handle failure case (e.g., show error toast)
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
}
}
@@ -0,0 +1,149 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun ImageMessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
val path = message.content.trim()
Column(modifier = modifier.fillMaxWidth()) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = headerText,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
modifier = Modifier.pointerInput(message.id) {
detectTapGestures(onTap = { pos ->
val layout = headerLayout ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(pos)
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
if (ann.isNotEmpty() && onNicknameClick != null) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(ann.first().item)
}
}, onLongPress = { onMessageLongPress?.invoke(message) })
},
onTextLayout = { headerLayout = it }
)
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) {
messages.filter { it.type == BitchatMessageType.Image }
.map { it.content.trim() }
}
if (bmp != null) {
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
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Box {
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
// Cyberpunk block-reveal while sending
BlockRevealImage(
bitmap = img,
progress = progressFraction,
blocksX = 24,
blocksY = 16,
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
}
)
} else {
// Fully revealed image
Image(
bitmap = img,
contentDescription = "Image",
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
.clickable {
val currentIndex = imagePaths.indexOf(path)
onImageClick?.invoke(path, imagePaths, currentIndex)
},
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 = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
}
} else {
Text(text = "[image unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
@@ -0,0 +1,44 @@
package com.bitchat.android.ui.media
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.media.ImageUtils
@Composable
fun ImagePickerButton(
modifier: Modifier = Modifier,
onImageReady: (String) -> Unit
) {
val context = LocalContext.current
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: android.net.Uri? ->
if (uri != null) {
val outPath = ImageUtils.downscaleAndSaveToAppFiles(context, uri)
if (!outPath.isNullOrBlank()) onImageReady(outPath)
}
}
IconButton(
onClick = { imagePicker.launch("image/*") },
modifier = modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Filled.Photo,
contentDescription = "Pick image",
tint = Color.Gray,
modifier = Modifier.size(20.dp)
)
}
}
@@ -0,0 +1,149 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
/**
* Media picker that offers image and file options
* Clicking opens a quick selection menu
*/
@Composable
fun MediaPickerOptions(
modifier: Modifier = Modifier,
onImagePick: (() -> Unit)? = null,
onFilePick: (() -> Unit)? = null
) {
var showOptions by remember { mutableStateOf(false) }
Box(modifier = modifier) {
// Main button
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp))
.background(color = Color.Gray.copy(alpha = 0.5f))
.clickable {
showOptions = true
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Pick media",
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
}
// Options menu (shown when clicked)
if (showOptions) {
Column(
modifier = Modifier
.graphicsLayer {
translationY = -120f // Position above the button
scaleX = 0.8f
scaleY = 0.8f
}
.zIndex(1f)
.clip(RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.surface)
.clickable {
showOptions = false
}
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Image option
onImagePick?.let { imagePick ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(color = MaterialTheme.colorScheme.primaryContainer)
.clickable {
showOptions = false
imagePick()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "Image",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
// File option
onFilePick?.let { filePick ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.clickable {
showOptions = false
filePick()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Description,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "File",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
// Clickable overlay to dismiss options
if (showOptions) {
Box(
modifier = Modifier
.size(400.dp)
.clickable {
showOptions = false
}
)
}
}
}
@@ -0,0 +1,79 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
/**
* Real-time scrolling waveform for recording: maintains a dense sliding window of bars.
* Pass in normalized amplitude [0f..1f]; the component handles sampling and drawing.
*/
@Composable
fun RealtimeScrollingWaveform(
modifier: Modifier = Modifier,
amplitudeNorm: Float,
bars: Int = 240,
barColor: Color = Color(0xFF00FF7F),
baseColor: Color = Color(0xFF444444)
) {
val latestAmp by rememberUpdatedState(amplitudeNorm)
val samples: SnapshotStateList<Float> = remember {
mutableStateListOf<Float>().also { list -> repeat(bars) { list.add(0f) } }
}
// Append samples on a steady cadence to create a smooth scroll
LaunchedEffect(bars) {
while (true) {
withFrameNanos { _: Long -> }
val v = latestAmp.coerceIn(0f, 1f)
samples.add(v)
val overflow = samples.size - bars
if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) }
kotlinx.coroutines.delay(20)
}
}
Canvas(modifier = modifier.fillMaxWidth()) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return@Canvas
val n = samples.size
if (n <= 0) return@Canvas
val stepX = w / n
val midY = h / 2f
val stroke = .5f.dp.toPx()
// Optional faint base to match chat density
// Draw bars with heavy dynamic range compression: quiet sounds almost at zero, loud sounds still prominent
for (i in 0 until n) {
val amp = samples[i].coerceIn(0f, 1f)
// Use squared amplitude to heavily compress small values while preserving high amplitudes
// This makes quiet sounds almost invisible but loud sounds still show prominently
val compressedAmp = amp * amp // amp^2
val lineH = (compressedAmp * (h * 0.9f)).coerceAtLeast(1f)
val x = i * stepX + stepX / 2f
val yTop = midY - lineH / 2f
val yBot = midY + lineH / 2f
drawLine(
color = barColor,
start = Offset(x, yTop),
end = Offset(x, yBot),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
}
@@ -0,0 +1,116 @@
package com.bitchat.android.ui.media
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.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
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.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.text.font.FontFamily
@Composable
fun VoiceNotePlayer(
path: String,
progressOverride: Float? = null,
progressColor: Color? = 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) }
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) {
val seekMs = (position * durationMs).toInt().coerceIn(0, durationMs)
try {
player.seekTo(seekMs)
progress = position // Update progress immediately for UI responsiveness
} catch (_: Exception) {}
}
}
LaunchedEffect(path) {
isPrepared = false
isError = false
progress = 0f
durationMs = 0
isPlaying = false
try {
player.reset()
player.setOnPreparedListener {
isPrepared = true
durationMs = try { player.duration } catch (_: Exception) { 0 }
}
player.setOnCompletionListener {
isPlaying = false
progress = 1f
}
player.setOnErrorListener { _, _, _ ->
isError = true
isPlaying = false
true
}
player.setDataSource(path)
player.prepareAsync()
} catch (_: Exception) {
isError = true
}
}
LaunchedEffect(isPlaying, isPrepared) {
try {
if (isPlaying && isPrepared) player.start() else if (isPrepared && player.isPlaying) player.pause()
} catch (_: Exception) {}
}
LaunchedEffect(isPlaying, isPrepared) {
while (isPlaying && isPrepared) {
progress = try { player.currentPosition.toFloat() / (player.duration.toFloat().coerceAtLeast(1f)) } catch (_: Exception) { 0f }
kotlinx.coroutines.delay(100)
}
}
DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
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)) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play"
)
}
val progressBarColor = progressColor ?: MaterialTheme.colorScheme.primary
com.bitchat.android.ui.media.WaveformPreview(
modifier = Modifier
.height(24.dp)
.weight(1f)
.padding(horizontal = 8.dp, vertical = 4.dp),
path = path,
sendProgress = progressOverride,
playbackProgress = if (progressOverride == null) progress else null,
onSeek = seekTo
)
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
}
}
@@ -0,0 +1,134 @@
package com.bitchat.android.ui.media
import androidx.compose.foundation.Canvas
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.withFrameNanos
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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(
modifier: Modifier = Modifier,
currentAmplitude: Float,
samples: SnapshotStateList<Float>,
maxSamples: Int = 120
) {
// Append samples at a fixed cadence while visible
val latestAmp by rememberUpdatedState(currentAmplitude)
LaunchedEffect(Unit) {
while (true) {
withFrameNanos { _: Long -> }
val v = latestAmp.coerceIn(0f, 1f)
samples.add(v)
val overflow = samples.size - maxSamples
if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) }
kotlinx.coroutines.delay(80)
}
}
WaveformCanvas(modifier = modifier, samples = samples, fillProgress = 1f, baseColor = Color(0xFF444444), fillColor = Color(0xFF00FF7F))
}
@Composable
fun WaveformPreview(
modifier: Modifier = Modifier,
path: String,
sendProgress: Float?,
playbackProgress: Float?,
onLoaded: ((FloatArray) -> Unit)? = null,
onSeek: ((Float) -> Unit)? = null
) {
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)
}
}
}
}
WaveformCanvas(
modifier = modifier,
samples = stateSamples,
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
)
}
@Composable
private fun WaveformCanvas(
modifier: Modifier,
samples: List<Float>,
fillProgress: Float,
baseColor: Color,
fillColor: Color,
onSeek: ((Float) -> Unit)? = null
) {
val seekModifier = if (onSeek != null) {
modifier.pointerInput(onSeek) {
detectTapGestures { offset ->
// Calculate the seek position as a fraction (0.0 to 1.0)
val position = offset.x / size.width.toFloat()
val clampedPosition = position.coerceIn(0f, 1f)
onSeek(clampedPosition)
}
}
} else {
modifier
}
Canvas(modifier = seekModifier.fillMaxWidth()) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return@Canvas
val n = samples.size
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) {
val amp = samples[i].coerceIn(0f, 1f)
val lineH = (amp * (h * 0.8f)).coerceAtLeast(2f)
val x = i * stepX + stepX / 2f
val yTop = midY - lineH / 2f
val yBot = midY + lineH / 2f
drawLine(
color = if (i <= filledUntil) fillColor else baseColor,
start = Offset(x, yTop),
end = Offset(x, yBot),
strokeWidth = stroke.width,
cap = StrokeCap.Round
)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="cache"
path="." />
<files-path
name="files"
path="." />
</paths>
@@ -0,0 +1,258 @@
package com.bitchat
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Date
@RunWith(RobolectricTestRunner::class)
class FileTransferTest {
@Test
fun `encode and decode file packet with all fields should preserve data`() {
// Given: Complete file packet
val contentArray = ByteArray(1024) { (it % 256).toByte() }
val originalPacket = BitchatFilePacket(
fileName = "test.png",
mimeType = "image/png",
fileSize = 1024000,
content = contentArray
)
// When: Encode and decode
val encoded = originalPacket.encode()
val decoded = BitchatFilePacket.decode(encoded!!)
// Then: Data should be preserved
assertNotNull(decoded)
assertEquals(originalPacket.fileName, decoded!!.fileName)
assertEquals(originalPacket.mimeType, decoded.mimeType)
assertEquals(originalPacket.fileSize, decoded.fileSize)
assertEquals(originalPacket.content.size, decoded.content.size)
for (i in 0 until originalPacket.content.size) {
assertEquals(originalPacket.content[i], decoded.content[i])
}
}
@Test
fun `encode file packet with filename should include filename TLV`() {
// Given: Packet with filename
val packet = BitchatFilePacket(
fileName = "myimage.jpg",
mimeType = "image/jpeg",
fileSize = 2048,
content = ByteArray(256) { 0xFF.toByte() }
)
// When: Encode
val encoded = packet.encode()
assertNotNull(encoded)
// Then: Should contain filename TLV
// FILE_NAME type (0x01) + length (9) + "myimage.jpg"
val expectedType = 0x01
val expectedLength = 9
val expectedFilename = "myimage.jpg".toByteArray(Charsets.UTF_8)
assertEquals(expectedType, encoded!![0])
assertEquals(expectedLength, (encoded[1].toInt() and 0xFF) or ((encoded[2].toInt() and 0xFF) shl 8))
val actualFilename = encoded!!.sliceArray(3 until 3 + expectedLength)
for (i in expectedFilename.indices) {
assertEquals(expectedFilename[i], actualFilename[i])
}
}
@Test
fun `encode file size should use big endian byte order for file size`() {
// Given: File with specific size
val fileSize = 0x12345678L
val packet = BitchatFilePacket(
fileName = "test.bin",
mimeType = "application/octet-stream",
fileSize = fileSize,
content = ByteArray(10)
)
// When: Encode
val encoded = packet.encode()
assertNotNull(encoded)
// Then: File size should be in big endian order
// Find FILE_SIZE TLV (type 0x02)
var offset = 0
while (offset < encoded!!.size - 1) {
if (encoded!![offset] == 0x02.toByte()) {
// This is FILE_SIZE TLV
offset += 1 // Skip type byte
val length = (encoded!![offset].toInt() and 0xFF) or ((encoded[offset + 1].toInt() and 0xFF) shl 8)
offset += 2 // Skip length bytes
if (length == 4) { // FILE_SIZE always has 4 bytes
val decodedFileSize = ByteBuffer.wrap(encoded!!.sliceArray(offset until offset + 4))
.order(ByteOrder.BIG_ENDIAN)
.int.toLong()
assertEquals(fileSize, decodedFileSize)
break
}
}
offset += 1
}
}
@Test
fun `decode minimal file packet should handle defaults correctly`() {
// Given: Minimal valid packet (the constructor requires non-null values)
val originalPacket = BitchatFilePacket(
fileName = "test",
mimeType = "application/octet-stream",
fileSize = 32, // Matches content size
content = ByteArray(32) { 0xAA.toByte() }
)
// When: Encode and decode
val encoded = originalPacket.encode()
val decoded = BitchatFilePacket.decode(encoded!!)
// Then: Data should be preserved completely
assertNotNull(decoded)
assertEquals(32, decoded!!.content.size)
for (i in 0 until 32) {
assertEquals(0xAA.toByte(), decoded.content[i])
}
assertEquals("test", decoded.fileName)
assertEquals("application/octet-stream", decoded.mimeType)
assertEquals(32L, decoded.fileSize)
}
@Test
fun `replaceFilePathInContent should correctly format content markers for different file types`() {
// Given: Different file types
val imageMessage = BitchatMessage(
id = "test1",
sender = "alice",
senderPeerID = "12345678",
content = "/data/user/0/com.bitchat.android/files/images/photo.jpg",
type = BitchatMessageType.Image,
timestamp = Date(System.currentTimeMillis()),
isPrivate = false
)
val audioMessage = BitchatMessage(
id = "test2",
sender = "bob",
senderPeerID = "87654321",
content = "/data/user/0/com.bitchat.android/files/audio/voice.amr",
type = BitchatMessageType.Audio,
timestamp = Date(System.currentTimeMillis()),
isPrivate = false
)
val fileMessage = BitchatMessage(
id = "test3",
sender = "charlie",
senderPeerID = "11223344",
content = "/data/user/0/com.bitchat.android/files/documents/document.pdf",
type = BitchatMessageType.File,
timestamp = Date(System.currentTimeMillis()),
isPrivate = false
)
// When: Converting to display format (this would be done in MessageMutable)
var result = imageMessage.content
result = result.replace(
"/data/user/0/com.bitchat.android/files/images/photo.jpg",
"[image] photo.jpg"
)
// Then: Should match expected pattern
assertEquals("[image] photo.jpg", result)
// Similar pattern for audio and file would be used in the actual implementation
}
@Test
fun `buildPrivateMessagePreview should generate user-friendly notifications for file types`() {
// Note: This test is for the NotificationTextUtils.buildPrivateMessagePreview function
// The actual function is in a separate utility file as part of the refactoring
// Given: Incoming image message
val imageMessage = BitchatMessage(
id = "test1",
sender = "alice",
senderPeerID = "1234abcd",
content = "📷 sent an image", // This would be the result of the utility function
type = BitchatMessageType.Image,
timestamp = Date(System.currentTimeMillis()),
isPrivate = true
)
// When: Building preview (this would call NotificationTextUtils.buildPrivateMessagePreview)
val preview = imageMessage.content // In actual code, this would be generated
// Then: Should provide user-friendly preview
assertEquals("📷 sent an image", preview)
// Additional assertions would test different file types
// Audio: "🎤 sent a voice message"
// File with specific extension: "📄 document.pdf"
// Generic file: "📎 sent a file"
}
@Test
fun `waveform extraction should handle empty audio data gracefully`() {
// This test would verify that empty or very short audio files
// don't cause crashes in waveform extraction
// Given: Empty audio data
val emptyAudioData = ByteArray(0)
// When: Attempting to extract waveform
// Note: Actual waveform extraction would be tested in the Waveform class
// This is a unit test placeholder
// Then: Should not crash and should return reasonable result
// For empty data, waveform might be empty array or default values
assertEquals(0, emptyAudioData.size)
}
@Test
fun `media picker should handle file size limits correctly`() {
// This test would verify that media file selection
// respects size limits before attempting transfer
// Given: Large file size (simulated)
val largeFileSize = 100L * 1024 * 1024 // 100MB
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
// When: Checking if file can be transferred
val isAllowed = largeFileSize <= maxAllowedSize
// Then: Should be rejected
assert(!isAllowed)
}
@Test
fun `transfer cancellation should cleanup resources properly`() {
// This test would verify that when a file transfer is cancelled,
// all associated resources are cleaned up
// Given: Active transfer in progress
val transferId = "test_transfer_123"
// When: Transfer is cancelled
// In the actual implementation, this would call cancellation logic
val cancelled = true // Simulated cancellation
// Then: Resources should be cleaned up
// This would verify temp files are deleted, progress tracking is cleared, etc.
assert(cancelled)
}
}