Camera take picture (#484)

* camera capture

* icon change

* image preview great

* remove string
This commit is contained in:
callebtc
2025-10-18 15:03:32 +02:00
committed by GitHub
parent af4dd175c4
commit 20342351a5
5 changed files with 162 additions and 26 deletions
+3
View File
@@ -104,6 +104,9 @@ dependencies {
// Security preferences // Security preferences
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation("androidx.exifinterface:exifinterface:1.3.7")
// Testing // Testing
testImplementation(libs.bundles.testing) testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -3,34 +3,109 @@ package com.bitchat.android.features.media
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream
object ImageUtils { 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 { return try {
val resolver = context.contentResolver 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 input = resolver.openInputStream(uri) ?: return null
val original = BitmapFactory.decodeStream(input) val original = BitmapFactory.decodeStream(input)
input.close() input.close()
original ?: return null 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 scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1) val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / 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 dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg") val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos -> FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos) scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
} }
if (scaled !== original) try { original.recycle() } catch (_: Exception) {} try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
try { if (scaled != original) scaled.recycle() } catch (_: Exception) {} try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
outFile.absolutePath outFile.absolutePath
} catch (e: Exception) { } catch (e: Exception) {
null 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
}
}
@@ -152,18 +152,8 @@ class ChatViewModel(
} }
fun cancelMediaSend(messageId: String) { fun cancelMediaSend(messageId: String) {
val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] } // Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
if (transferId != null) { mediaSendingManager.cancelMediaSend(messageId)
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)
}
}
}
} }
private fun loadAndInitialize() { private fun loadAndInitialize() {
@@ -275,6 +275,9 @@ class MediaSendingManager(
if (transferId != null) { if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId) val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) { 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 // Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId) messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) { 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 * Update progress for a transfer
*/ */
@@ -1,26 +1,37 @@
package com.bitchat.android.ui.media package com.bitchat.android.ui.media
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.foundation.layout.size
import androidx.compose.material.icons.Icons 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.Photo
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.runtime.*
import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils import com.bitchat.android.features.media.ImageUtils
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ImagePickerButton( fun ImagePickerButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onImageReady: (String) -> Unit onImageReady: (String) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var capturedImagePath by remember { mutableStateOf<String?>(null) }
val imagePicker = rememberLauncherForActivityResult( val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri: android.net.Uri? -> ) { uri: android.net.Uri? ->
@@ -29,17 +40,57 @@ fun ImagePickerButton(
if (!outPath.isNullOrBlank()) onImageReady(outPath) 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( fun startCameraCapture() {
onClick = { imagePicker.launch("image/*") }, try {
modifier = modifier.size(32.dp) 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( Icon(
imageVector = Icons.Filled.Photo, imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image), contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = Color.Gray, tint = Color.Gray,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
}
// No custom preview: native camera UI handles confirmation
}