mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user