mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:25:21 +00:00
Camera take picture (#484)
* camera capture * icon change * image preview great * remove string
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user