Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.bitchat.android.features.media

import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
import java.io.File

object MediaSizeLimiter {
private fun formatBytes(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var size = bytes.toDouble()
var unit = 0
while (size >= 1024 && unit < units.size - 1) {
size /= 1024.0
unit++
}
return "%.1f %s".format(size, units[unit])
}

private fun toastTooLarge(context: Context, label: String) {
val maxLabel = formatBytes(com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES)
Toast.makeText(context, "$label is too large to send (max $maxLabel)", Toast.LENGTH_SHORT).show()
}

fun queryContentLength(context: Context, uri: Uri): Long? {
// Try OpenableColumns.SIZE first
val sizeFromQuery = try {
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.SIZE)
if (idx >= 0 && c.moveToFirst()) c.getLong(idx) else null
}
} catch (_: Exception) { null }

if (sizeFromQuery != null && sizeFromQuery >= 0) return sizeFromQuery

// Fallback to file descriptor statSize
val sizeFromFd = try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize }
} catch (_: Exception) { null }

return sizeFromFd?.takeIf { it >= 0 }
}

// Returns false if too large and shows a toast
fun enforceUriPrecheck(context: Context, uri: Uri, label: String): Boolean {
val len = queryContentLength(context, uri)
if (len != null && len > com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES) {
toastTooLarge(context, label)
return false
}
return true
}

// Returns false if too large. Optionally deletes the file if too large.
fun enforcePathPostCheck(context: Context, path: String, label: String, deleteIfTooLarge: Boolean = true): Boolean {
return try {
val file = File(path)
val len = file.length()
if (len > com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES) {
if (deleteIfTooLarge) runCatching { file.delete() }
toastTooLarge(context, label)
false
} else true
} catch (_: Exception) { true }
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ class BluetoothPacketBroadcaster(
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
// 20ms delay between fragments
delay(20)
delay(50)
if (transferId != null) {
sent += 1
TransferProgressManager.progress(transferId, sent, fragments.size)
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}

// Determine whether to show media capture/pick buttons
val isGeohashTimeline = selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location

ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
Expand Down Expand Up @@ -226,7 +229,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme
colorScheme = colorScheme,
showMediaButtons = !isGeohashTimeline
)
}

Expand Down Expand Up @@ -382,7 +386,8 @@ private fun ChatInputSection(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme
colorScheme: ColorScheme,
showMediaButtons: Boolean
) {
Surface(
modifier = Modifier.fillMaxWidth(),
Expand Down Expand Up @@ -418,7 +423,8 @@ private fun ChatInputSection(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
showMediaButtons = showMediaButtons
)
}
}
Expand Down
14 changes: 2 additions & 12 deletions app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,8 @@ class ChatViewModel(
}

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)
}
}
}
// Delegate to MediaSendingManager which maintains the transferId<->messageId mapping
mediaSendingManager.cancelMediaSend(messageId)
}

private fun loadAndInitialize() {
Expand Down
57 changes: 47 additions & 10 deletions app/src/main/java/com/bitchat/android/ui/InputComponents.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ 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
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Surface
import androidx.compose.ui.text.style.TextAlign

/**
* Input components for ChatScreen
Expand Down Expand Up @@ -170,7 +173,8 @@ fun MessageInput(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showMediaButtons: Boolean = true
) {
val colorScheme = MaterialTheme.colorScheme
val isFocused = remember { mutableStateOf(false) }
Expand Down Expand Up @@ -237,7 +241,7 @@ fun MessageInput(
val secs = (elapsedMs / 1000).toInt()
val mm = secs / 60
val ss = secs % 60
val maxSecs = 10 // 10 second max recording time
val maxSecs = com.bitchat.android.util.AppConstants.Media.MAX_RECORDING_SECONDS
val maxMm = maxSecs / 60
val maxSs = maxSecs % 60
Text(
Expand All @@ -263,15 +267,14 @@ fun MessageInput(
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)

// Image button (image picker) - hide during recording
if (!isRecording) {
if (!isRecording && showMediaButtons) {
// 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)
// }
//)
FilePickerButton(
onFileReady = { path ->
onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
}
)
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
Expand All @@ -282,7 +285,7 @@ fun MessageInput(

Spacer(Modifier.width(1.dp))

VoiceRecordButton(
if (showMediaButtons) VoiceRecordButton(
backgroundColor = bg,
onStart = {
isRecording = true
Expand Down Expand Up @@ -311,6 +314,17 @@ fun MessageInput(
path
)
}
) else SlashCommandButton(
onClick = {
val newText = "/"
onValueChange(
TextFieldValue(
text = newText,
selection = androidx.compose.ui.text.TextRange(newText.length)
)
)
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
)

} else {
Expand Down Expand Up @@ -364,6 +378,29 @@ fun MessageInput(
// Auto-stop handled inside VoiceRecordButton
}

@Composable
private fun SlashCommandButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
val colorScheme = MaterialTheme.colorScheme
Surface(
onClick = onClick,
shape = CircleShape,
color = colorScheme.background,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, colorScheme.outline.copy(alpha = 0.4f)),
modifier = modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "/",
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = colorScheme.primary,
textAlign = TextAlign.Center
)
}
}
}

@Composable
fun CommandSuggestionsBox(
suggestions: List<CommandSuggestion>,
Expand Down
31 changes: 25 additions & 6 deletions app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ class MediaSendingManager(
}
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)")
if (file.length() > com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: ${com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES})")
return
}

Expand Down Expand Up @@ -74,8 +74,8 @@ class MediaSendingManager(
}
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)")
if (file.length() > com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: ${com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES})")
return
}

Expand Down Expand Up @@ -112,8 +112,8 @@ class MediaSendingManager(
}
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)")
if (file.length() > com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: ${com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES})")
return
}

Expand Down Expand Up @@ -276,6 +276,11 @@ class MediaSendingManager(
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Remove the message from chat upon explicit cancel
// Also attempt to delete the associated outgoing file
runCatching { findMessagePathById(messageId) }.
getOrNull()?.let { path ->
try { java.io.File(path).takeIf { it.exists() }?.delete() } catch (_: Exception) {}
}
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
Expand All @@ -285,6 +290,20 @@ class MediaSendingManager(
}
}

private fun findMessagePathById(messageId: String): String? {
// Check main messages
state.getMessagesValue().firstOrNull { it.id == messageId }?.content?.let { return it }
// Check private chats
state.getPrivateChatsValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
// Check channels
state.getChannelMessagesValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
return null
}

/**
* Update progress for a transfer
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ fun VoiceRecordButton(
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) {
// Auto-stop after configured max duration
if (elapsedMs >= com.bitchat.android.util.AppConstants.Media.MAX_RECORDING_MS && isRecording) {
val file = recorder?.stop()
isRecording = false
recorder = null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.bitchat.android.ui.media

import android.net.Uri
import com.bitchat.android.features.media.MediaSizeLimiter
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.size
Expand Down Expand Up @@ -30,10 +31,12 @@ fun FilePickerButton(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
// Pre-check size via resolver metadata
if (!MediaSizeLimiter.enforceUriPrecheck(context, uri, "File")) return@rememberLauncherForActivityResult
// 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)
if (!path.isNullOrBlank() && MediaSizeLimiter.enforcePathPostCheck(context, path, label = "File", deleteIfTooLarge = true)) onFileReady(path)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.media.ImageUtils
import com.bitchat.android.features.media.MediaSizeLimiter

@Composable
fun ImagePickerButton(
Expand All @@ -26,7 +27,7 @@ fun ImagePickerButton(
) { uri: android.net.Uri? ->
if (uri != null) {
val outPath = ImageUtils.downscaleAndSaveToAppFiles(context, uri)
if (!outPath.isNullOrBlank()) onImageReady(outPath)
if (!outPath.isNullOrBlank() && MediaSizeLimiter.enforcePathPostCheck(context, outPath, label = "Image", deleteIfTooLarge = true)) onImageReady(outPath)
}
}

Expand All @@ -42,4 +43,3 @@ fun ImagePickerButton(
)
}
}

4 changes: 3 additions & 1 deletion app/src/main/java/com/bitchat/android/util/AppConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ object AppConstants {
}

object Media {
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
const val MAX_FILE_SIZE_BYTES: Long = 1L * 1024 * 1024
const val MAX_RECORDING_SECONDS: Int = 30
const val MAX_RECORDING_MS: Long = MAX_RECORDING_SECONDS * 1000L
}

object Services {
Expand Down
Loading