mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +00:00
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:
@@ -12,6 +12,7 @@ captures/
|
||||
debug_keystore/
|
||||
*.keystore
|
||||
debug.keystore
|
||||
app/release
|
||||
|
||||
# Gradle
|
||||
/build/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Device Monitoring Manager — Design and Integration
|
||||
|
||||
This change introduces a lean DeviceMonitoringManager to strictly manage BLE device connections while keeping the existing code structure intact.
|
||||
|
||||
## Goals
|
||||
|
||||
- Maintain a blocklist of device MAC addresses to deny incoming/outgoing connections.
|
||||
- Drop and block connections that never ANNOUNCE within 15 seconds of establishment.
|
||||
- Drop and block connections that go silent (no packets) for over 60 seconds.
|
||||
- Block devices that experience 5 error disconnects within a 5-minute window.
|
||||
- Auto-unblock devices after 15 minutes.
|
||||
|
||||
## Implementation Overview
|
||||
|
||||
File: `app/src/main/java/com/bitchat/android/mesh/DeviceMonitoringManager.kt`
|
||||
|
||||
- Thread-safe maps with coroutine-based timers.
|
||||
- Minimal surface area: a few clearly named entry points to hook into existing flows.
|
||||
- Callbacks to perform disconnects without coupling to GATT APIs.
|
||||
|
||||
Key logic:
|
||||
- `isBlocked(address)`: check if a MAC is blocked (auto-clears on expiry).
|
||||
- `block(address, reason)`: add MAC to blocklist (15m), disconnect via callback, auto-unblock later.
|
||||
- `onConnectionEstablished(address)`: start 15s “first ANNOUNCE” timer and a 60s inactivity timer.
|
||||
- `onAnnounceReceived(address)`: cancel the 15s ANNOUNCE timer for that device.
|
||||
- `onAnyPacketReceived(address)`: refresh 60s inactivity timer.
|
||||
- `onDeviceDisconnected(address, status)`: track error disconnects and block on 5 within 5 minutes.
|
||||
|
||||
Timers:
|
||||
- ANNOUNCE timer: 15 seconds from connection establishment.
|
||||
- Inactivity timer: resets on any packet; fires after 60 seconds of silence.
|
||||
- Blocklist TTL: 15 minutes per device (auto-unblock job per entry).
|
||||
|
||||
## Wiring Points (Minimal Changes)
|
||||
|
||||
1) Connection Manager
|
||||
- File: `BluetoothConnectionManager.kt`
|
||||
- Added a `DeviceMonitoringManager` instance and provided a `disconnectCallback` that:
|
||||
- disconnects client GATT connections via `BluetoothConnectionTracker`.
|
||||
- cancels server connections via `BluetoothGattServer.cancelConnection`.
|
||||
- Exposed `noteAnnounceReceived(address)` as a small helper for higher layers.
|
||||
- Updated `componentDelegate.onPacketReceived` to notify per-device activity to the monitor.
|
||||
|
||||
2) GATT Client
|
||||
- File: `BluetoothGattClientManager.kt`
|
||||
- Constructor now receives `deviceMonitor`.
|
||||
- Before attempting any outgoing connection (from scan or direct connect), deny if blocked.
|
||||
- On connection setup complete (after CCCD enable), call `deviceMonitor.onConnectionEstablished(addr)`.
|
||||
- On incoming packet (`onCharacteristicChanged`), call `deviceMonitor.onAnyPacketReceived(addr)`.
|
||||
- On disconnect, call `deviceMonitor.onDeviceDisconnected(addr, status)` to track error bursts.
|
||||
|
||||
3) GATT Server
|
||||
- File: `BluetoothGattServerManager.kt`
|
||||
- Constructor now receives `deviceMonitor`.
|
||||
- On incoming connection, immediately deny (cancelConnection) if blocked, before tracking it.
|
||||
- On connection setup complete (descriptor enable) and also after initial connect, start monitoring via `onConnectionEstablished(addr)`.
|
||||
- On packet write, call `deviceMonitor.onAnyPacketReceived(addr)`.
|
||||
- On disconnect, call `deviceMonitor.onDeviceDisconnected(addr, status)`.
|
||||
|
||||
4) ANNOUNCE Binding
|
||||
- File: `BluetoothMeshService.kt` (in the ANNOUNCE handler where we first map device → peer)
|
||||
- After mapping a device address to a peer on first verified ANNOUNCE, call `connectionManager.noteAnnounceReceived(address)` to cancel the 15s timer for that device.
|
||||
|
||||
## Behavior Summary
|
||||
|
||||
- Blocked devices:
|
||||
- Outgoing: client will not initiate connections.
|
||||
- Incoming: server cancels the connection immediately.
|
||||
- Existing connection: monitor disconnects instantly and blocks for 15 minutes.
|
||||
|
||||
- No ANNOUNCE within 15s of connection:
|
||||
- Connection is dropped and device is blocked for 15 minutes.
|
||||
|
||||
- No packets for >60s:
|
||||
- Connection is dropped and device is blocked for 15 minutes.
|
||||
|
||||
- >=5 error disconnects within 5 minutes:
|
||||
- Device is blocked for 15 minutes.
|
||||
|
||||
- Auto-unblock:
|
||||
- Every block entry automatically expires after 15 minutes.
|
||||
|
||||
## Debug Logging
|
||||
|
||||
- The manager emits chat-visible debug messages through `DebugSettingsManager` (SystemMessage), e.g.:
|
||||
- Blocking decisions and reasons
|
||||
- Auto-unblock events
|
||||
- ANNOUNCE wait start/cancel
|
||||
- Inactivity timer set and inactivity-triggered blocks
|
||||
- Burst error disconnect threshold reached
|
||||
- Additional enforcement logs are added in GATT client/server when a blocked device is denied.
|
||||
- Logs appear in the chat when verbose logging is enabled in Debug settings.
|
||||
|
||||
## Panic Triple-Tap
|
||||
|
||||
- Triple-tapping the title now also clears the device blocklist and all device tracking:
|
||||
- Calls `BluetoothMeshService.clearAllInternalData()` which triggers `BluetoothConnectionManager.clearDeviceMonitoringAndTracking()`.
|
||||
- This disconnects active connections, clears the monitor’s blocklist and timers, and resets the `BluetoothConnectionTracker` state.
|
||||
|
||||
## Notes and Rationale
|
||||
|
||||
- The monitoring manager is intentionally decoupled from GATT specifics via a disconnect callback. This keeps responsibilities separate and avoids plumbing GATT instances through unrelated classes.
|
||||
- Packet activity is captured in both client and server data paths as early as possible to ensure the inactivity timer is accurate even before higher-level processing.
|
||||
- The “first ANNOUNCE” check uses the same mapping event that sets `addressPeerMap` to avoid false positives on unverified announces.
|
||||
|
||||
## Touched Files
|
||||
|
||||
- Added: `mesh/DeviceMonitoringManager.kt`
|
||||
- Updated: `mesh/BluetoothConnectionManager.kt`
|
||||
- Updated: `mesh/BluetoothGattClientManager.kt`
|
||||
- Updated: `mesh/BluetoothGattServerManager.kt`
|
||||
- Updated: `mesh/BluetoothMeshService.kt`
|
||||
|
||||
These changes are small, local, and respect existing structure without broad refactors.
|
||||
@@ -0,0 +1,442 @@
|
||||
# Bitchat Bluetooth File Transfer: Images, Audio, and Generic Files (with Interactive Features)
|
||||
|
||||
This document is the exhaustive implementation guide for Bitchat’s Bluetooth file transfer protocol for voice notes (audio) and images, including interactive features like waveform seeking. It describes the on‑wire packet format (both v1 and v2), fragmentation/progress/cancellation, sender/receiver behaviors, and the complete UX we implemented in the Android client so that other implementers can interoperate and match the user experience precisely.
|
||||
|
||||
**Protocol Versions:**
|
||||
- **v1**: Original protocol with 2‑byte payload length (≤ 64 KiB files)
|
||||
- **v2**: Extended protocol with 4-byte payload length (≤ 4 GiB files) - use for all file transfers
|
||||
- File transfer packets use v2 format by default for optimal compatibility
|
||||
|
||||
**Interactive Features:**
|
||||
- **Waveform Seeking**: Tap anywhere on audio waveforms to jump to that playback position
|
||||
- **Large File Support**: v2 protocol enables multi-GiB file transfers through fragmentation
|
||||
- **Unified Experience**: Identical UX between platforms with enhanced user control
|
||||
|
||||
The guide is organized into:
|
||||
|
||||
- Protocol overview (BitchatPacket + File Transfer payload)
|
||||
- Fragmentation, progress reporting, and cancellation
|
||||
- Receive path, validation, and persistence
|
||||
- Sender path (audio + images)
|
||||
- Interactive features (audio waveform seeking)
|
||||
- UI/UX behavior (recording, sending, playback, image rendering)
|
||||
- File inventory (source files and their roles)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1) Protocol Overview
|
||||
|
||||
Bitchat BLE transport carries application messages inside the common `BitchatPacket` envelope. File transfer reuses the same envelope as public and private messages, with a distinct `type` and a TLV‑encoded payload.
|
||||
|
||||
### 1.1 BitchatPacket envelope
|
||||
|
||||
Fields (subset relevant to file transfer):
|
||||
|
||||
- `version: UByte` — protocol version (`1` for v1, `2` for v2 with extended payload length).
|
||||
- `type: UByte` — message type. File transfer uses `MessageType.FILE_TRANSFER (0x22)`.
|
||||
- `senderID: ByteArray (8)` — 8‑byte binary peer ID.
|
||||
- `recipientID: ByteArray (8)` — 8‑byte recipient. For public: `SpecialRecipients.BROADCAST (0xFF…FF)`; for private: the target peer’s 8‑byte ID.
|
||||
- `timestamp: ULong` — milliseconds since epoch.
|
||||
- `payload: ByteArray` — TLV file payload (see below).
|
||||
- `signature: ByteArray?` — optional signature (present for private sends in our implementation, to match iOS integrity path).
|
||||
- `ttl: UByte` — hop TTL (we use `MAX_TTL` for broadcast, `7` for private).
|
||||
|
||||
Envelope creation and broadcast paths are implemented in:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt)
|
||||
|
||||
Private sends are additionally encrypted at the higher layer (Noise) for text messages, but file transfers use the `FILE_TRANSFER` message type in the clear at the envelope level with content carried inside a TLV. See code for any deployment‑specific enforcement.
|
||||
|
||||
### 1.2 Binary Protocol Extensions (v2)
|
||||
|
||||
#### v2 Header Format Changes
|
||||
|
||||
**v1 Format (original):**
|
||||
```
|
||||
Header (13 bytes):
|
||||
Version: 1 byte
|
||||
Type: 1 byte
|
||||
TTL: 1 byte
|
||||
Timestamp: 8 bytes
|
||||
Flags: 1 byte
|
||||
PayloadLength: 2 bytes (big-endian, max 64 KiB)
|
||||
```
|
||||
|
||||
**v2 Format (extended):**
|
||||
```
|
||||
Header (15 bytes):
|
||||
Version: 1 byte (set to 2 for v2 packets)
|
||||
Type: 1 byte
|
||||
TTL: 1 byte
|
||||
Timestamp: 8 bytes
|
||||
Flags: 1 byte
|
||||
PayloadLength: 4 bytes (big-endian, max ~4 GiB)
|
||||
```
|
||||
|
||||
- **Header Size**: Increased from 13 to 15 bytes.
|
||||
- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4 bytes), allowing file transfers up to ~4 GiB.
|
||||
- **Backward Compatibility**: Clients must support both v1 and v2 decoding. File transfer packets always use v2.
|
||||
- **Implementation**: See `BinaryProtocol.kt` with `getHeaderSize(version)` logic.
|
||||
|
||||
#### Use Cases for v2
|
||||
- **Large Audio Files**: Professional recordings, podcasts, or music samples.
|
||||
- **High-Resolution Images**: Full-resolution photos from modern smartphones.
|
||||
- **Future File Types**: PDFs, documents, archives, or other large media.
|
||||
|
||||
#### Interoperability Requirements
|
||||
- Clients receiving v2 packets must decode 4-byte `PayloadLength` fields.
|
||||
- Clients sending file transfers should preferentially use v2 format.
|
||||
- Fragmentation still applies: large files are split into fragments that fit within BLE MTU constraints (~128 KiB per fragment).
|
||||
|
||||
### 1.3 File Transfer TLV payload (BitchatFilePacket)
|
||||
|
||||
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.
|
||||
|
||||
- Defined in `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt)
|
||||
|
||||
Canonical TLVs (v2 spec):
|
||||
|
||||
- `0x01 FILE_NAME` — UTF‑8 bytes
|
||||
- Encoding: `type(1) + len(2) + value`
|
||||
- `0x02 FILE_SIZE` — 4 bytes (UInt32, big‑endian)
|
||||
- Encoding: `type(1) + len(2=4) + value(4)`
|
||||
- Note: v1 used 8 bytes (UInt64). v2 standardizes to 4 bytes. See Legacy Compatibility below.
|
||||
- `0x03 MIME_TYPE` — UTF‑8 bytes (e.g., `image/jpeg`, `audio/mp4`, `application/pdf`)
|
||||
- Encoding: `type(1) + len(2) + value`
|
||||
- `0x04 CONTENT` — raw file bytes
|
||||
- Encoding: `type(1) + len(4) + value(len)`
|
||||
- Exactly one CONTENT TLV per file payload in v2 (no TLV‑level chunking); overall packet fragmentation happens at the transport layer.
|
||||
|
||||
Encoding rules:
|
||||
|
||||
- Standard TLVs use `1 byte type + 2 bytes big‑endian length + value`.
|
||||
- CONTENT uses a 4‑byte big‑endian length to allow payloads well beyond 64 KiB.
|
||||
- With the v2 envelope (4‑byte payload length), CONTENT can be large; transport still fragments oversize packets to fit BLE MTU.
|
||||
- Implementations should validate TLV boundaries; decoding should fail fast on malformed structures.
|
||||
|
||||
Decoding rules (v2):
|
||||
|
||||
- Accept the canonical TLVs above. Unknown TLVs should be ignored or cause failure per implementation policy (current Android rejects unknown types).
|
||||
- FILE_SIZE expects `len=4` and is parsed as UInt32; receivers may upcast to 64‑bit internally.
|
||||
- CONTENT expects a 4‑byte length field and a single occurrence; if multiple CONTENT TLVs are present, concatenate in order (defensive tolerance).
|
||||
- If FILE_SIZE is missing, receivers may fall back to `content.size`.
|
||||
- If MIME_TYPE is missing, default to `application/octet-stream`.
|
||||
|
||||
Legacy Compatibility (optional, for mixed‑version meshes):
|
||||
|
||||
- FILE_SIZE (0x02): Some legacy senders used 8‑byte UInt64. Decoders MAY accept `len=8` and clamp to 32‑bit if needed.
|
||||
- CONTENT (0x04): Legacy payloads might have used a 2‑byte TLV length with multiple CONTENT chunks. Decoders MAY support concatenating multiple CONTENT TLVs with 2‑byte lengths if encountered.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2) Fragmentation, Progress, and Cancellation
|
||||
|
||||
### 2.1 Fragmentation
|
||||
|
||||
File transfers reuse the mesh broadcaster’s fragmentation logic:
|
||||
|
||||
- `BluetoothPacketBroadcaster` checks if the serialized envelope exceeds the configured MTU and splits it into fragments via `FragmentManager`.
|
||||
- Fragments are sent with a short inter‑fragment delay (currently ~200 ms; matches iOS/Rust behavior notes in code).
|
||||
- When only one fragment is needed, send as a single packet.
|
||||
|
||||
### 2.2 Transfer ID and progress events
|
||||
|
||||
We derive a deterministic transfer ID to track progress:
|
||||
|
||||
- `transferId = sha256Hex(packet.payload)` (hex string of the file TLV payload).
|
||||
|
||||
The broadcaster emits progress events to a shared flow:
|
||||
|
||||
- `TransferProgressManager.start(id, totalFragments)`
|
||||
- `TransferProgressManager.progress(id, sent, totalFragments)`
|
||||
- `TransferProgressManager.complete(id, totalFragments)`
|
||||
|
||||
The UI maps `transferId → messageId`, then updates `DeliveryStatus.PartiallyDelivered(sent, total)` as events arrive; when `complete`, switches to `Delivered`.
|
||||
|
||||
### 2.3 Cancellation
|
||||
|
||||
Transfers are cancellable mid‑flight:
|
||||
|
||||
- The broadcaster keeps a `transferId → Job` map and cancels the job to stop sending remaining fragments.
|
||||
- API path:
|
||||
- `BluetoothPacketBroadcaster.cancelTransfer(transferId)`
|
||||
- Exposed via `BluetoothConnectionManager.cancelTransfer` and `BluetoothMeshService.cancelFileTransfer`.
|
||||
- `ChatViewModel.cancelMediaSend(messageId)` resolves `messageId → transferId` and cancels.
|
||||
- UX: tapping the “X” on a sending media removes the message from the timeline immediately.
|
||||
|
||||
Implementation files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3) Receive Path and Persistence
|
||||
|
||||
Receiver dispatch is in `MessageHandler`:
|
||||
|
||||
- For both broadcast and private paths we try `BitchatFilePacket.decode(payload)`. If it decodes:
|
||||
- The file is persisted under app files with type‑specific subfolders:
|
||||
- Audio: `files/voicenotes/incoming/`
|
||||
- Image: `files/images/incoming/`
|
||||
- Other files: `files/files/incoming/`
|
||||
- Filename strategy:
|
||||
- Prefer the transmitted `fileName` when present; sanitize path separators.
|
||||
- Ensure uniqueness by appending `" (n)"` before the extension when a name exists already.
|
||||
- If `fileName` is absent, derive from MIME with a sensible default extension.
|
||||
- MIME determines extension hints (`.m4a`, `.mp3`, `.wav`, `.ogg` for audio; `.jpg`, `.png`, `.webp` for images; otherwise based on MIME or `.bin`).
|
||||
- A synthetic chat message is created with content markers pointing to the local path:
|
||||
- Audio: `"[voice] /abs/path/to/file"`
|
||||
- Image: `"[image] /abs/path/to/file"`
|
||||
- Other: `"[file] /abs/path/to/file"`
|
||||
- `senderPeerID` is set to the origin, `isPrivate` set appropriately.
|
||||
|
||||
Files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4) Sender Path
|
||||
|
||||
### 4.1 Audio (Voice Notes)
|
||||
|
||||
1) Capture
|
||||
- Hold‑to‑record mic button starts `MediaRecorder` with AAC in MP4 (`audio/mp4`).
|
||||
- Sample rate: 44100 Hz, channels: mono, bitrate: ~32 kbps (to reduce payload size for BLE).
|
||||
- On release, we pad 500 ms before stopping to avoid clipping endings.
|
||||
- Files saved under `files/voicenotes/outgoing/voice_YYYYMMDD_HHMMSS.m4a`.
|
||||
|
||||
2) Local echo
|
||||
- We create a `BitchatMessage` with content `"[voice] <path>"` and add to the appropriate timeline (public/channel/private).
|
||||
- For private: `messageManager.addPrivateMessage(peerID, message)`. For public/channel: `messageManager.addMessage(message)` or add to channel.
|
||||
|
||||
3) Packet creation
|
||||
- Build a `BitchatFilePacket`:
|
||||
- `fileName`: basename (e.g., `voice_… .m4a`)
|
||||
- `fileSize`: file length
|
||||
- `mimeType`: `audio/mp4`
|
||||
- `content`: full bytes (ensure content ≤ 64 KiB; with chosen codec params typical short notes fit fragmentation constraints)
|
||||
- Encode TLV; compute `transferId = sha256Hex(payload)`.
|
||||
- Map `transferId → messageId` for UI progress.
|
||||
|
||||
4) Send
|
||||
- Public: `BluetoothMeshService.sendFileBroadcast(filePacket)`.
|
||||
- Private: `BluetoothMeshService.sendFilePrivate(peerID, filePacket)`.
|
||||
- Broadcaster handles fragmentation and progress emission.
|
||||
|
||||
5) Waveform
|
||||
- We extract a 120‑bin waveform from the recorded file (the same extractor used for the receiver) and cache by file path, so sender and receiver waveforms are identical.
|
||||
|
||||
Core files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (sendVoiceNote) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt)
|
||||
- `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt)
|
||||
- `app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt)
|
||||
- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` (cache + extractor) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt)
|
||||
|
||||
### 4.2 Images
|
||||
|
||||
1) Selection and processing
|
||||
- System picker (Storage Access Framework) with `GetContent()` (`image/*`). No storage permission required.
|
||||
- Selected image is downscaled so longest edge is 512 px; saved as JPEG (85% quality) under `files/images/outgoing/img_<timestamp>.jpg`.
|
||||
- Helper: `ImageUtils.downscaleAndSaveToAppFiles(context, uri, maxDim=512)`.
|
||||
|
||||
2) Local echo
|
||||
- Insert a message with `"[image] <path>"` in the current context (public/channel/private).
|
||||
|
||||
3) Packet creation
|
||||
- Build `BitchatFilePacket` with mime `image/jpeg` and file content.
|
||||
- Encode TLV + compute `transferId` and map to `messageId`.
|
||||
|
||||
4) Send
|
||||
- Same paths as audio (broadcast/private), including fragmentation and progress emission.
|
||||
|
||||
Core files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` (sendImageNote) (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5) UI / UX Details
|
||||
|
||||
This section specifies exactly what users see and how inputs behave, so alternative clients can match the experience.
|
||||
|
||||
### 5.1 Message input area
|
||||
|
||||
- The input field remains mounted at all times to prevent the IME (keyboard) from collapsing during long‑press interactions (recording). We overlay recording UI atop the text field rather than replacing it.
|
||||
- While recording, the text caret (cursor) is hidden by setting a transparent cursor brush.
|
||||
- Mentions and slash commands are styled with a monospace look and color coding.
|
||||
|
||||
Files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/InputComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/InputComponents.kt)
|
||||
|
||||
### 5.2 Recording UX
|
||||
|
||||
- Hold the mic button to start recording. Recording runs until release, then we pad 500 ms and stop.
|
||||
- While recording, a dense, real‑time scrolling waveform overlays the input showing live audio; a timer is shown to the right.
|
||||
- Component: `RealtimeScrollingWaveform` (dense bars, ~240 columns, ~20 FPS) in `app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt`.
|
||||
- The keyboard stays visible; the caret is hidden.
|
||||
- On release, we immediately show a local echo message for the voice note and start sending.
|
||||
|
||||
### 5.3 Voice note rendering
|
||||
|
||||
- Displayed with a header (nickname + timestamp) then the waveform + controls row.
|
||||
- Waveform
|
||||
- A 120‑bin static waveform is rendered per file, identical for sender and receiver, extracted from the actual audio file.
|
||||
- During send, the waveform fills left→right in blue based on fragment progress.
|
||||
- During playback, the waveform fills left→right in green based on player progress.
|
||||
- Controls
|
||||
- Play/Pause toggle to the left of the waveform; duration text to the right.
|
||||
- Cancel sending
|
||||
- While sending a voice note, a round “X” cancel button appears to the right of the controls. Tapping cancels the transfer mid‑flight.
|
||||
|
||||
Files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt)
|
||||
- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt)
|
||||
|
||||
### 5.4 Image sending UX
|
||||
|
||||
- A circular “+” button next to the mic opens the system image picker. After selection, we downscale to 512 px longest edge and show a local echo; the send begins immediately.
|
||||
- Progress visualization
|
||||
- Instead of a linear progress bar, we reveal the image block‑by‑block (modem‑era homage).
|
||||
- The image is divided into a constant grid (default 24×16), and the blocks are rendered in order based on fragment progress; there are no gaps between tiles.
|
||||
- The cancel “X” button overlays the top‑right corner during sending.
|
||||
- On cancel, the message is removed from the chat immediately.
|
||||
|
||||
Files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt)
|
||||
- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt)
|
||||
|
||||
### 5.5 Image receiving UX
|
||||
|
||||
- Received images render fully with rounded corners and are left‑aligned like text messages.
|
||||
- Tapping an image opens a fullscreen viewer with an option to save to the device Downloads via `MediaStore`.
|
||||
|
||||
Files:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5.6 Interactive Audio Features
|
||||
|
||||
### 5.6.1 Waveform Seeking
|
||||
|
||||
- Audio waveforms in chat messages are fully interactive: users can tap anywhere on the waveform to jump to that position in the audio playback.
|
||||
- On tap, the seek position is calculated as a fraction of the waveform width (0.0 = beginning, 1.0 = end).
|
||||
- This works for both playing and paused audio states.
|
||||
- The MediaPlayer is seeked to the calculated position immediately, with visual feedback via progress bar update.
|
||||
- Tapping provides precise control - e.g., tap 25% through waveform jumps to 25% through audio.
|
||||
- No haptic feedback or visual indicator; the progress bar update serves as immediate feedback.
|
||||
|
||||
Waveform Canvas Implementation:
|
||||
- `WaveformCanvas` uses `pointerInput` with `detectTapGestures` to capture tap events.
|
||||
- Tap position is converted to a fraction: `position.x / size.width.toFloat()`.
|
||||
- Clamped to 0.0-1.0 range for safety.
|
||||
- `onSeek` callback is invoked with the calculated position fraction.
|
||||
- Only enabled when `onSeek` is provided (disabled for sending in progress).
|
||||
|
||||
VoiceNotePlayer Seeking:
|
||||
- Accepts position fraction (0.0-1.0) and converts to milliseconds: `seekMs = (position * durationMs).toInt()`.
|
||||
- Calls `MediaPlayer.seekTo(seekMs)` to jump to the exact position.
|
||||
- Updates progress state immediately for UI responsiveness even before playback reaches the new position.
|
||||
|
||||
Files:
|
||||
- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt) — VoiceNotePlayer with seekTo function
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt) — Interactive WaveformCanvas with tap handling
|
||||
|
||||
---
|
||||
|
||||
## 6) Edge Cases and Notes
|
||||
|
||||
- Filename collisions on receiver: prefer the sender‑supplied name if present; always uniquify with a ` (n)` suffix before the extension to prevent overwrites.
|
||||
- Path markers in messages
|
||||
- We use simple content markers: `"[voice] <abs path>", "[image] <abs path>", "[file] <abs path>"` for local rendering. These are not sent on the wire; the actual file bytes are inside the TLV payload.
|
||||
- Progress math for images relies on `(sent / total)` from `TransferProgressManager` (fragment‑level granularity). The block grid density can be tuned; currently 24×16.
|
||||
- Private vs public: both use the same file TLV; only the envelope `recipientID` differs. Private may have signatures; code shows a signing step consistent with iOS behavior prior to broadcast to ensure integrity.
|
||||
- BLE timing: there is a 200 ms inter‑fragment delay for stability. Adjust as needed for your radio stack while maintaining compatibility.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7) File Inventory (Added/Changed)
|
||||
|
||||
Core protocol and transport:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt` — TLV payload model + encode/decode. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/model/BitchatFilePacket.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt` — packet creation and broadcast for file messages. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt` — fragmentation, progress, cancellation via transfer jobs. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt` — progress events bus. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/TransferProgressManager.kt)
|
||||
- `app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt` — receive path: decode, persist to files, create chat messages. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt)
|
||||
|
||||
Audio capture and waveform:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt` — MediaRecorder wrapper. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/VoiceRecorder.kt)
|
||||
- `app/src/main/java/com/bitchat/android/features/voice/Waveform.kt` — cache + extractor + resampler. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/voice/Waveform.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt` — Compose waveform preview components. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt)
|
||||
|
||||
Image pipeline:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt` — downscale and save to app files. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt` — SAF picker button. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt` — block‑reveal progress renderer (no gaps, dense grid). (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/BlockRevealImage.kt)
|
||||
|
||||
Recording overlay:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt` — dense, real‑time scrolling waveform during recording. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/RealtimeScrollingWaveform.kt)
|
||||
|
||||
UI composition and view model coordination:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/InputComponents.kt` — input field, overlays (recording), picker button, mic. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/InputComponents.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/MessageComponents.kt` — message rendering for text/audio/images including progress UIs and cancel overlays. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt` — sendVoiceNote/sendImageNote, progress mapping, cancelMediaSend. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt)
|
||||
- `app/src/main/java/com/bitchat/android/ui/MessageManager.kt` — add/remove/update messages across main, private, and channels. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageManager.kt)
|
||||
|
||||
Fullscreen image:
|
||||
|
||||
- `app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt` — fullscreen viewer + save to Downloads. (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 8) Implementation Checklist for Other Clients
|
||||
|
||||
1. **Implement v2 protocol support**: Support both v1 (2-byte payload length) and v2 (4-byte payload length) packet decoding. Use v2 format for file transfer packets to enable large file transfers.
|
||||
2. Implement `BitchatFilePacket` TLV exactly as specified:
|
||||
- FILE_NAME and MIME_TYPE: `type(1) + len(2) + value`
|
||||
- FILE_SIZE: `type(1) + len(2=4) + value(4, UInt32 BE)`
|
||||
- CONTENT: `type(1) + len(4) + value`
|
||||
3. Embed the TLV into a `BitchatPacket` envelope with `type = FILE_TRANSFER (0x22)` and the correct `recipientID` (broadcast vs private).
|
||||
4. Fragment, send, and report progress using a transfer ID derived from `sha256(payload)` so the UI can map progress to a message.
|
||||
5. Support cancellation at the fragment sender: stop sending remaining fragments and propagate a cancel to the UI (we remove the message).
|
||||
6. On receive, decode TLV, persist to an app directory (separate audio/images/other), and create a chat message with content marker `"[voice] path"`, `"[image] path"`, or `"[file] path"` for local rendering.
|
||||
7. Audio sender and receiver should use the same waveform extractor so visuals match; a 120‑bin histogram is a good balance.
|
||||
8. **Implement interactive waveform seeking**: Tap waveforms to jump to that audio position. Calculate tap position as fraction (0.0-1.0) of waveform width.
|
||||
9. For images, optionally downscale to keep TLV small; JPEG 85% at 512 px longest edge is a good baseline.
|
||||
10. Mirror the UX:
|
||||
- Recording overlay that does not collapse the IME; hide the caret while recording; add 500 ms end padding.
|
||||
- Voice: waveform fill for send/playback; cancel overlay; **tap-to-seek support**.
|
||||
- Images: dense block‑reveal with no gaps during sending; cancel overlay; fullscreen viewer with save.
|
||||
- Generic files: render as a file pill with icon + filename; support open/save via the host OS.
|
||||
|
||||
Following the above should produce an interoperable and matching experience across platforms.
|
||||
Reference in New Issue
Block a user