From 20342351a5b640a791742e7e410d1b9632a05d3f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 18 Oct 2025 15:03:32 +0200 Subject: [PATCH] Camera take picture (#484) * camera capture * icon change * image preview great * remove string --- app/build.gradle.kts | 3 + .../android/features/media/ImageUtils.kt | 89 +++++++++++++++++-- .../com/bitchat/android/ui/ChatViewModel.kt | 14 +-- .../bitchat/android/ui/MediaSendingManager.kt | 17 ++++ .../android/ui/media/ImagePickerButton.kt | 65 ++++++++++++-- 5 files changed, 162 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2cc16c02..486ec156 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,6 +104,9 @@ dependencies { // Security preferences implementation(libs.androidx.security.crypto) + // EXIF orientation handling for images + implementation("androidx.exifinterface:exifinterface:1.3.7") + // Testing testImplementation(libs.bundles.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt b/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt index 75d1ab9d..18104cd4 100644 --- a/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/media/ImageUtils.kt @@ -3,34 +3,109 @@ package com.bitchat.android.features.media import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface import java.io.File import java.io.FileOutputStream +import java.io.InputStream object ImageUtils { - fun downscaleAndSaveToAppFiles(context: Context, uri: android.net.Uri, maxDim: Int = 512, quality: Int = 85): String? { + fun downscaleAndSaveToAppFiles(context: Context, uri: Uri, maxDim: Int = 512, quality: Int = 85): String? { return try { val resolver = context.contentResolver + val exifRotation = resolver.openInputStream(uri)?.use { getRotationDegreesFromExif(it) } ?: 0 + + // Reopen for decode as the previous stream is consumed 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 oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original + + val w = oriented.width + val h = oriented.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 scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented 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) {} + try { if (oriented !== original) original.recycle() } catch (_: Exception) {} + try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {} outFile.absolutePath } catch (e: Exception) { null } } -} + fun downscalePathAndSaveToAppFiles(context: Context, path: String, maxDim: Int = 512, quality: Int = 85): String? { + return try { + val original = BitmapFactory.decodeFile(path) ?: return null + val exifRotation = getRotationDegreesFromExif(path) + val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original + + val w = oriented.width + val h = oriented.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(oriented, newW, newH, true) else oriented + 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) + } + try { if (oriented !== original) original.recycle() } catch (_: Exception) {} + try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {} + outFile.absolutePath + } catch (e: Exception) { + null + } + } + + fun loadBitmapWithExifOrientation(path: String): Bitmap? { + return try { + val base = BitmapFactory.decodeFile(path) ?: return null + val rotation = getRotationDegreesFromExif(path) + if (rotation != 0) rotateBitmap(base, rotation) else base + } catch (_: Exception) { + null + } + } + + private fun rotateBitmap(src: Bitmap, degrees: Int): Bitmap { + return try { + val m = Matrix() + m.postRotate(degrees.toFloat()) + Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also { + try { src.recycle() } catch (_: Exception) {} + } + } catch (_: Exception) { + src + } + } + + private fun getRotationDegreesFromExif(path: String): Int = try { + val exif = ExifInterface(path) + orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + } catch (_: Exception) { 0 } + + private fun getRotationDegreesFromExif(stream: InputStream): Int = try { + val exif = ExifInterface(stream) + orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + } catch (_: Exception) { 0 } + + private fun orientationToDegrees(orientation: Int): Int = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_ROTATE_180 -> 180 + ExifInterface.ORIENTATION_ROTATE_270 -> 270 + ExifInterface.ORIENTATION_TRANSPOSE -> 90 + ExifInterface.ORIENTATION_TRANSVERSE -> 270 + else -> 0 + } +} 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 b0661c56..74ab15c6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -152,18 +152,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 tracks transfer IDs and cleans up UI state + mediaSendingManager.cancelMediaSend(messageId) } private fun loadAndInitialize() { 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 0a6be528..a3def523 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -275,6 +275,9 @@ class MediaSendingManager( if (transferId != null) { val cancelled = meshService.cancelFileTransfer(transferId) if (cancelled) { + // Try to remove cached local file for this message (if any) + runCatching { findMessagePathById(messageId)?.let { java.io.File(it).delete() } } + // Remove the message from chat upon explicit cancel messageManager.removeMessageById(messageId) synchronized(transferMessageMap) { @@ -285,6 +288,20 @@ class MediaSendingManager( } } + private fun findMessagePathById(messageId: String): String? { + // Search main timeline + state.getMessagesValue().firstOrNull { it.id == messageId }?.content?.let { return it } + // Search private chats + state.getPrivateChatsValue().values.forEach { list -> + list.firstOrNull { it.id == messageId }?.content?.let { return it } + } + // Search channel messages + 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/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index 662ffc39..78c9f786 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 @@ -1,26 +1,37 @@ 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.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils +import java.io.File +@OptIn(ExperimentalFoundationApi::class) @Composable fun ImagePickerButton( modifier: Modifier = Modifier, onImageReady: (String) -> Unit ) { val context = LocalContext.current + var capturedImagePath by remember { mutableStateOf(null) } + val imagePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: android.net.Uri? -> @@ -29,17 +40,57 @@ fun ImagePickerButton( if (!outPath.isNullOrBlank()) onImageReady(outPath) } } + + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val path = capturedImagePath + if (success && !path.isNullOrBlank()) { + // Downscale + correct orientation, then send; delete original + val outPath = com.bitchat.android.features.media.ImageUtils.downscalePathAndSaveToAppFiles(context, path) + if (!outPath.isNullOrBlank()) { + onImageReady(outPath) + } + runCatching { File(path).delete() } + } else { + // Cleanup on cancel/failure + path?.let { runCatching { File(it).delete() } } + } + capturedImagePath = null + } - IconButton( - onClick = { imagePicker.launch("image/*") }, - modifier = modifier.size(32.dp) + fun startCameraCapture() { + try { + val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() } + val file = File(dir, "camera_${System.currentTimeMillis()}.jpg") + val uri = FileProvider.getUriForFile( + context, + context.packageName + ".fileprovider", + file + ) + capturedImagePath = file.absolutePath + takePictureLauncher.launch(uri) + } catch (_: Exception) { + // Ignore errors; no-op + } + } + + Box( + modifier = modifier + .size(32.dp) + .combinedClickable( + onClick = { imagePicker.launch("image/*") }, + onLongClick = { startCameraCapture() } + ), + contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Filled.Photo, + imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), tint = Color.Gray, modifier = Modifier.size(20.dp) ) } -} + // No custom preview: native camera UI handles confirmation +}