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
}
}
}