diff --git a/app/src/main/java/com/bitchat/android/features/media/MediaSizeLimiter.kt b/app/src/main/java/com/bitchat/android/features/media/MediaSizeLimiter.kt new file mode 100644 index 000000000..e65fe23c5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/features/media/MediaSizeLimiter.kt @@ -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 } + } +} + diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index b34742177..6f669dde5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index fa7a861d5..92c5e80fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 -> @@ -226,7 +229,8 @@ fun ChatScreen(viewModel: ChatViewModel) { selectedPrivatePeer = selectedPrivatePeer, currentChannel = currentChannel, nickname = nickname, - colorScheme = colorScheme + colorScheme = colorScheme, + showMediaButtons = !isGeohashTimeline ) } @@ -382,7 +386,8 @@ private fun ChatInputSection( selectedPrivatePeer: String?, currentChannel: String?, nickname: String, - colorScheme: ColorScheme + colorScheme: ColorScheme, + showMediaButtons: Boolean ) { Surface( modifier = Modifier.fillMaxWidth(), @@ -418,7 +423,8 @@ private fun ChatInputSection( selectedPrivatePeer = selectedPrivatePeer, currentChannel = currentChannel, nickname = nickname, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + showMediaButtons = showMediaButtons ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 8255eb068..f971f7607 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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() { diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 087e632d2..26a8403a2 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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 @@ -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) } @@ -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( @@ -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) @@ -282,7 +285,7 @@ fun MessageInput( Spacer(Modifier.width(1.dp)) - VoiceRecordButton( + if (showMediaButtons) VoiceRecordButton( backgroundColor = bg, onStart = { isRecording = true @@ -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 { @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index 0a6be5289..b5f72c741 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -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 } @@ -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 } @@ -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 } @@ -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) @@ -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 */ diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a0..c46cd0ce2 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt index 16d92d7a0..fcf7d8f36 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -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 @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index 662ffc39b..1c8079b20 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -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( @@ -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) } } @@ -42,4 +43,3 @@ fun ImagePickerButton( ) } } - diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index ce396b9a9..034341048 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -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 {