Media transfers (#440)

* tor voice wip

* worky BLE a bit

* can send sound

* remove tor

* ui cleanup

* recording time

* progress bar color

* nicknames for audio

* onboarding permissions no microphone

* may work

* fix destionation

* extend

* refactor voice input component

* fix keyboard collapse issue

* send images

* wip image open

* image sending works

* wip waveforms

* better

* better animation

* fix cursor for sending audio

* image sending animation

* image sending animation

* full screen image viewer

* gossip sync for fragments too

* reduce delays

* fix keyboard focus

* use v2 for file transfers

* do not sync fragments

* scrollable image viewer

* ui

* ui adjustments

* nicer animation

* seek through audio

* add spec

* add more details to documentation

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

* kinda wip

* fix buttons

* files half working

* wip file transfer

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

* clean

* remove gossip sync for fragments

* fix audio and image rendering

* adjust FILE_SIZE TLV size too

* cleanup

* haptic

* private messages media

* read receipts for media

* use enum for message type not string

* delivery ack checks dont push content

* check

* animation fix

* refactor

* ui adjustments

* comments

* refactor

* fix crash on send and receive of the same file

* refactor notifications

* tests
This commit is contained in:
callebtc
2025-09-19 22:46:14 +02:00
committed by GitHub
parent 1178fc254a
commit 633a506753
57 changed files with 4801 additions and 138 deletions
@@ -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)
}
}