18 changed files with 890 additions and 6 deletions
@ -0,0 +1,52 @@ |
|||||||
|
package com.utun.chat.data |
||||||
|
|
||||||
|
import android.graphics.Bitmap |
||||||
|
import android.graphics.BitmapFactory |
||||||
|
import android.graphics.Matrix |
||||||
|
import android.media.ExifInterface |
||||||
|
|
||||||
|
object PhotoUtil { |
||||||
|
|
||||||
|
private fun orientation(path: String): Int = try { |
||||||
|
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) |
||||||
|
} catch (_: Exception) { |
||||||
|
ExifInterface.ORIENTATION_NORMAL |
||||||
|
} |
||||||
|
|
||||||
|
private fun isRotated90(orientation: Int): Boolean = when (orientation) { |
||||||
|
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_270, |
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_TRANSVERSE -> true |
||||||
|
else -> false |
||||||
|
} |
||||||
|
|
||||||
|
fun orientedDimensions(path: String): Pair<Int, Int> { |
||||||
|
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } |
||||||
|
BitmapFactory.decodeFile(path, opts) |
||||||
|
val w = opts.outWidth |
||||||
|
val h = opts.outHeight |
||||||
|
if (w <= 0 || h <= 0) return Pair(0, 0) |
||||||
|
return if (isRotated90(orientation(path))) Pair(h, w) else Pair(w, h) |
||||||
|
} |
||||||
|
|
||||||
|
fun decodeOriented(path: String, inSampleSize: Int): Bitmap? { |
||||||
|
val o = orientation(path) |
||||||
|
val opts = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize } |
||||||
|
val raw = try { BitmapFactory.decodeFile(path, opts) } catch (_: Exception) { null } ?: return null |
||||||
|
val matrix = Matrix() |
||||||
|
when (o) { |
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) |
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) |
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> { matrix.setRotate(180f); matrix.postScale(-1f, 1f) } |
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.setRotate(90f); matrix.postScale(-1f, 1f) } |
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) |
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.setRotate(-90f); matrix.postScale(-1f, 1f) } |
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) |
||||||
|
else -> return raw |
||||||
|
} |
||||||
|
val rotated = try { |
||||||
|
Bitmap.createBitmap(raw, 0, 0, raw.width, raw.height, matrix, true) |
||||||
|
} catch (_: Exception) { raw } |
||||||
|
if (rotated !== raw) raw.recycle() |
||||||
|
return rotated |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
package com.utun.chat.ui.components |
||||||
|
|
||||||
|
import android.graphics.Bitmap |
||||||
|
import androidx.compose.foundation.Image |
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.runtime.* |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.geometry.Offset |
||||||
|
import androidx.compose.ui.graphics.asImageBitmap |
||||||
|
import androidx.compose.ui.graphics.graphicsLayer |
||||||
|
import androidx.compose.ui.input.pointer.pointerInput |
||||||
|
import androidx.compose.ui.layout.ContentScale |
||||||
|
import androidx.compose.ui.platform.LocalDensity |
||||||
|
|
||||||
|
private const val MAX_ZOOM = 4f |
||||||
|
|
||||||
|
private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float): Offset { |
||||||
|
val mx = ((cW * scale - cW) / 2f).coerceAtLeast(0f) |
||||||
|
val my = ((cH * scale - cH) / 2f).coerceAtLeast(0f) |
||||||
|
return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my)) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) { |
||||||
|
var zoomScale by remember(bitmap) { mutableStateOf(1f) } |
||||||
|
var zoomOffset by remember(bitmap) { mutableStateOf(Offset.Zero) } |
||||||
|
|
||||||
|
BoxWithConstraints(modifier) { |
||||||
|
val density = LocalDensity.current |
||||||
|
val cW = with(density) { maxWidth.toPx() } |
||||||
|
val cH = with(density) { maxHeight.toPx() } |
||||||
|
|
||||||
|
LaunchedEffect(cW, cH) { |
||||||
|
zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH) |
||||||
|
} |
||||||
|
|
||||||
|
Image( |
||||||
|
bitmap.asImageBitmap(), |
||||||
|
contentDescription = null, |
||||||
|
contentScale = contentScale, |
||||||
|
modifier = Modifier |
||||||
|
.fillMaxSize() |
||||||
|
.graphicsLayer { |
||||||
|
scaleX = zoomScale |
||||||
|
scaleY = zoomScale |
||||||
|
translationX = zoomOffset.x |
||||||
|
translationY = zoomOffset.y |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
Box( |
||||||
|
modifier = Modifier |
||||||
|
.fillMaxSize() |
||||||
|
.pointerInput(Unit) { |
||||||
|
detectTransformGestures { centroid, pan, zoom, _ -> |
||||||
|
val old = zoomScale |
||||||
|
val new = (zoomScale * zoom).coerceIn(1f, MAX_ZOOM) |
||||||
|
val z = new / old |
||||||
|
val rel = centroid - Offset(cW / 2f, cH / 2f) |
||||||
|
val raw = Offset( |
||||||
|
zoomOffset.x * z + rel.x * (1f - z) + pan.x, |
||||||
|
zoomOffset.y * z + rel.y * (1f - z) + pan.y |
||||||
|
) |
||||||
|
zoomScale = new |
||||||
|
zoomOffset = clampOffset(new, raw, cW, cH) |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,347 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import android.Manifest |
||||||
|
import android.content.pm.PackageManager |
||||||
|
import android.graphics.Bitmap |
||||||
|
import android.util.Size |
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult |
||||||
|
import androidx.activity.result.contract.ActivityResultContracts |
||||||
|
import androidx.camera.core.Camera |
||||||
|
import androidx.camera.core.CameraSelector |
||||||
|
import androidx.camera.core.ImageCapture |
||||||
|
import androidx.camera.core.ImageCaptureException |
||||||
|
import androidx.camera.core.Preview |
||||||
|
import androidx.camera.core.resolutionselector.ResolutionSelector |
||||||
|
import androidx.camera.core.resolutionselector.ResolutionStrategy |
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider |
||||||
|
import androidx.camera.view.PreviewView |
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.foundation.shape.CircleShape |
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send |
||||||
|
import androidx.compose.material.icons.filled.Close |
||||||
|
import androidx.compose.material.icons.filled.Delete |
||||||
|
import androidx.compose.material.icons.filled.FlashOff |
||||||
|
import androidx.compose.material.icons.filled.FlashOn |
||||||
|
import androidx.compose.material3.* |
||||||
|
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.platform.LocalLifecycleOwner |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import androidx.compose.ui.unit.sp |
||||||
|
import androidx.compose.ui.viewinterop.AndroidView |
||||||
|
import androidx.core.content.ContextCompat |
||||||
|
import com.utun.chat.data.CameraLensManager |
||||||
|
import com.utun.chat.data.LensOption |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import com.utun.chat.data.PhotoResolution |
||||||
|
import com.utun.chat.data.PhotoUtil |
||||||
|
import com.utun.chat.ui.components.ZoomableImage |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.withContext |
||||||
|
import java.io.File |
||||||
|
import java.util.concurrent.Executors |
||||||
|
|
||||||
|
private enum class PhotoPhase { IDLE, CAPTURING, CAPTURED } |
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class) |
||||||
|
@Composable |
||||||
|
fun PhotoRecordScreen( |
||||||
|
onSend: (String, Int, Int) -> Unit, |
||||||
|
onClose: () -> Unit |
||||||
|
) { |
||||||
|
val context = LocalContext.current |
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current |
||||||
|
val cameraExecutor = remember { Executors.newSingleThreadExecutor() } |
||||||
|
|
||||||
|
var hasCamera by remember { |
||||||
|
mutableStateOf( |
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED |
||||||
|
) |
||||||
|
} |
||||||
|
val camLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCamera = it } |
||||||
|
|
||||||
|
LaunchedEffect(Unit) { if (!hasCamera) camLauncher.launch(Manifest.permission.CAMERA) } |
||||||
|
|
||||||
|
val providerRef = remember { mutableStateOf<ProcessCameraProvider?>(null) } |
||||||
|
var lenses by remember { mutableStateOf<List<LensOption>>(emptyList()) } |
||||||
|
var selectedLens by remember { mutableStateOf<LensOption?>(null) } |
||||||
|
var resolutions by remember { mutableStateOf<List<PhotoResolution>>(emptyList()) } |
||||||
|
var selectedResolution by remember { mutableStateOf<PhotoResolution?>(null) } |
||||||
|
|
||||||
|
val imageCapture = remember(selectedResolution) { |
||||||
|
val builder = ImageCapture.Builder() |
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) |
||||||
|
selectedResolution?.let { r -> |
||||||
|
val selector = ResolutionSelector.Builder() |
||||||
|
.setResolutionStrategy(ResolutionStrategy( |
||||||
|
Size(r.width, r.height), |
||||||
|
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER)) |
||||||
|
.build() |
||||||
|
builder.setResolutionSelector(selector) |
||||||
|
} |
||||||
|
builder.build() |
||||||
|
} |
||||||
|
val preview = remember { Preview.Builder().build() } |
||||||
|
val currentPreview by rememberUpdatedState(preview) |
||||||
|
val currentImageCapture by rememberUpdatedState(imageCapture) |
||||||
|
|
||||||
|
var phase by remember { mutableStateOf(PhotoPhase.IDLE) } |
||||||
|
var outputFile by remember { mutableStateOf<File?>(null) } |
||||||
|
var photoWidth by remember { mutableStateOf(0) } |
||||||
|
var photoHeight by remember { mutableStateOf(0) } |
||||||
|
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) } |
||||||
|
var boundCamera by remember { mutableStateOf<Camera?>(null) } |
||||||
|
var torchOn by remember { mutableStateOf(false) } |
||||||
|
|
||||||
|
fun bindCamera(selector: CameraSelector) { |
||||||
|
val provider = providerRef.value ?: return |
||||||
|
try { |
||||||
|
provider.unbindAll() |
||||||
|
boundCamera = provider.bindToLifecycle(lifecycleOwner, selector, currentPreview, currentImageCapture) |
||||||
|
torchOn = false |
||||||
|
LogManager.addLog("INFO", "PhotoRecord", "camera bound") |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "PhotoRecord", "bind failed: ${e.message}") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun selectLens(lens: LensOption) { |
||||||
|
selectedLens = lens |
||||||
|
val provider = providerRef.value |
||||||
|
val list = if (provider != null) CameraLensManager.photoResolutions(provider, lens.cameraId) else emptyList() |
||||||
|
resolutions = list |
||||||
|
selectedResolution = list.lastOrNull() |
||||||
|
bindCamera(lens.selector()) |
||||||
|
} |
||||||
|
|
||||||
|
fun readDimensions(file: File): Pair<Int, Int> = PhotoUtil.orientedDimensions(file.absolutePath) |
||||||
|
|
||||||
|
fun capture() { |
||||||
|
if (phase != PhotoPhase.IDLE) return |
||||||
|
val file = File(context.cacheDir, "photo_msg_${System.currentTimeMillis()}.jpg") |
||||||
|
outputFile = file |
||||||
|
phase = PhotoPhase.CAPTURING |
||||||
|
try { |
||||||
|
val options = ImageCapture.OutputFileOptions.Builder(file).build() |
||||||
|
imageCapture.takePicture(options, cameraExecutor, object : ImageCapture.OnImageSavedCallback { |
||||||
|
override fun onImageSaved(results: ImageCapture.OutputFileResults) { |
||||||
|
val dims = readDimensions(file) |
||||||
|
photoWidth = dims.first |
||||||
|
photoHeight = dims.second |
||||||
|
LogManager.addLog("INFO", "PhotoRecord", "captured: ${file.absolutePath} (${file.length()} bytes) ${dims.first}x${dims.second}") |
||||||
|
phase = PhotoPhase.CAPTURED |
||||||
|
} |
||||||
|
override fun onError(exception: ImageCaptureException) { |
||||||
|
LogManager.addLog("ERROR", "PhotoRecord", "capture error: ${exception.message}") |
||||||
|
file.delete() |
||||||
|
outputFile = null |
||||||
|
phase = PhotoPhase.IDLE |
||||||
|
} |
||||||
|
}) |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "PhotoRecord", "capture failed: ${e.message}") |
||||||
|
phase = PhotoPhase.IDLE |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fun discardAndReset() { |
||||||
|
outputFile?.delete() |
||||||
|
outputFile = null |
||||||
|
previewBitmap = null |
||||||
|
photoWidth = 0 |
||||||
|
photoHeight = 0 |
||||||
|
phase = PhotoPhase.IDLE |
||||||
|
LogManager.addLog("INFO", "PhotoRecord", "discarded") |
||||||
|
} |
||||||
|
|
||||||
|
/* load downsampled preview when captured */ |
||||||
|
LaunchedEffect(phase) { |
||||||
|
if (phase == PhotoPhase.CAPTURED) { |
||||||
|
val file = outputFile ?: return@LaunchedEffect |
||||||
|
previewBitmap = withContext(Dispatchers.IO) { PhotoUtil.decodeOriented(file.absolutePath, 4) } |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* rebind camera after resolution change (imageCapture is rebuilt) */ |
||||||
|
LaunchedEffect(selectedResolution) { |
||||||
|
if (providerRef.value != null) { |
||||||
|
selectedLens?.let { bindCamera(it.selector()) } |
||||||
|
LogManager.addLog("INFO", "PhotoRecord", "resolution switched to ${selectedResolution?.label ?: "full"}") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* init provider + enumerate lenses + bind front */ |
||||||
|
DisposableEffect(hasCamera) { |
||||||
|
if (hasCamera) { |
||||||
|
val future = ProcessCameraProvider.getInstance(context) |
||||||
|
future.addListener({ |
||||||
|
try { |
||||||
|
val provider = future.get() |
||||||
|
providerRef.value = provider |
||||||
|
val list = CameraLensManager.enumerate(provider) |
||||||
|
lenses = list |
||||||
|
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull() |
||||||
|
if (default != null) { |
||||||
|
selectLens(default) |
||||||
|
} |
||||||
|
} catch (e: Exception) { |
||||||
|
LogManager.addLog("ERROR", "PhotoRecord", "provider init failed: ${e.message}") |
||||||
|
} |
||||||
|
}, ContextCompat.getMainExecutor(context)) |
||||||
|
} |
||||||
|
onDispose { |
||||||
|
providerRef.value?.unbindAll() |
||||||
|
providerRef.value = null |
||||||
|
cameraExecutor.shutdown() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Scaffold( |
||||||
|
topBar = { |
||||||
|
TopAppBar( |
||||||
|
title = { Text("Фото") }, |
||||||
|
navigationIcon = { |
||||||
|
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") } |
||||||
|
}, |
||||||
|
actions = { |
||||||
|
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) { |
||||||
|
IconButton(onClick = { |
||||||
|
val next = !torchOn |
||||||
|
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {} |
||||||
|
torchOn = next |
||||||
|
}) { |
||||||
|
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
) { padding -> |
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) { |
||||||
|
if (!hasCamera) { |
||||||
|
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { |
||||||
|
Text("Нужно разрешение камеры", color = Color.White, fontSize = 16.sp) |
||||||
|
Spacer(Modifier.height(12.dp)) |
||||||
|
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") } |
||||||
|
} |
||||||
|
} else if (phase == PhotoPhase.CAPTURED) { |
||||||
|
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { |
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { |
||||||
|
previewBitmap?.let { ZoomableImage(it, Modifier.fillMaxSize()) } |
||||||
|
} |
||||||
|
Text( |
||||||
|
"${photoWidth}×${photoHeight}", |
||||||
|
color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp |
||||||
|
) |
||||||
|
Spacer(Modifier.height(8.dp)) |
||||||
|
} |
||||||
|
} else { |
||||||
|
AndroidView( |
||||||
|
factory = { ctx -> |
||||||
|
PreviewView(ctx).also { pv -> |
||||||
|
pv.scaleType = PreviewView.ScaleType.FIT_CENTER |
||||||
|
preview.surfaceProvider = pv.surfaceProvider |
||||||
|
} |
||||||
|
}, |
||||||
|
modifier = Modifier.fillMaxSize() |
||||||
|
) |
||||||
|
|
||||||
|
/* lens chips */ |
||||||
|
if (phase == PhotoPhase.IDLE) { |
||||||
|
Column( |
||||||
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
||||||
|
lenses.forEach { lens -> |
||||||
|
val selected = selectedLens?.cameraId == lens.cameraId |
||||||
|
Surface( |
||||||
|
shape = RoundedCornerShape(16.dp), |
||||||
|
color = if (selected) Color(0xFFFFD600) else Color(0x66000000), |
||||||
|
modifier = Modifier.clickable { selectLens(lens) } |
||||||
|
) { |
||||||
|
Text( |
||||||
|
lens.label, |
||||||
|
color = if (selected) Color.Black else Color.White, |
||||||
|
fontSize = 13.sp, |
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
Spacer(Modifier.height(8.dp)) |
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
||||||
|
resolutions.forEach { r -> |
||||||
|
val selected = r == selectedResolution |
||||||
|
Surface( |
||||||
|
shape = RoundedCornerShape(16.dp), |
||||||
|
color = if (selected) Color(0xFFFFD600) else Color(0x66000000), |
||||||
|
modifier = Modifier.clickable { selectedResolution = r } |
||||||
|
) { |
||||||
|
Text( |
||||||
|
r.label, |
||||||
|
color = if (selected) Color.Black else Color.White, |
||||||
|
fontSize = 13.sp, |
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* bottom controls */ |
||||||
|
Column( |
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() |
||||||
|
.navigationBarsPadding().padding(16.dp), |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) { |
||||||
|
if (phase == PhotoPhase.CAPTURED) { |
||||||
|
Box( |
||||||
|
modifier = Modifier.size(52.dp) |
||||||
|
.background(Color(0x66000000), CircleShape) |
||||||
|
.clickable { discardAndReset() }, |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Icon(Icons.Filled.Delete, "Переснять", tint = Color.White, modifier = Modifier.size(26.dp)) |
||||||
|
} |
||||||
|
Spacer(Modifier.width(28.dp)) |
||||||
|
Box( |
||||||
|
modifier = Modifier.size(52.dp) |
||||||
|
.background(Color(0xFF4CAF50), CircleShape) |
||||||
|
.clickable { |
||||||
|
outputFile?.let { f -> if (f.length() > 0) onSend(f.absolutePath, photoWidth, photoHeight) else onClose() } |
||||||
|
}, |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp)) |
||||||
|
} |
||||||
|
} else { |
||||||
|
/* shutter */ |
||||||
|
Box( |
||||||
|
modifier = Modifier.size(76.dp) |
||||||
|
.background(Color.White.copy(alpha = 0.9f), CircleShape) |
||||||
|
.clickable(enabled = phase == PhotoPhase.IDLE) { capture() }, |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
Box( |
||||||
|
Modifier.size(60.dp) |
||||||
|
.background(if (phase == PhotoPhase.CAPTURING) Color(0x88FFFFFF) else Color.White, CircleShape) |
||||||
|
.padding(4.dp) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
package com.utun.chat.ui.screens |
||||||
|
|
||||||
|
import android.app.Activity |
||||||
|
import android.content.Context |
||||||
|
import android.content.ContextWrapper |
||||||
|
import android.graphics.Bitmap |
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.layout.* |
||||||
|
import androidx.compose.material.icons.Icons |
||||||
|
import androidx.compose.material.icons.filled.Close |
||||||
|
import androidx.compose.material3.Icon |
||||||
|
import androidx.compose.material3.IconButton |
||||||
|
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.unit.dp |
||||||
|
import androidx.core.view.WindowCompat |
||||||
|
import androidx.core.view.WindowInsetsCompat |
||||||
|
import androidx.core.view.WindowInsetsControllerCompat |
||||||
|
import com.utun.chat.data.LogManager |
||||||
|
import com.utun.chat.data.PhotoUtil |
||||||
|
import com.utun.chat.ui.components.ZoomableImage |
||||||
|
import kotlinx.coroutines.Dispatchers |
||||||
|
import kotlinx.coroutines.withContext |
||||||
|
|
||||||
|
private const val MAX_DECODE_DIM = 4096 |
||||||
|
|
||||||
|
private tailrec fun Context.findActivity(): Activity? = when (this) { |
||||||
|
is Activity -> this |
||||||
|
is ContextWrapper -> baseContext.findActivity() |
||||||
|
else -> null |
||||||
|
} |
||||||
|
|
||||||
|
private fun decodeBitmap(path: String): Bitmap? { |
||||||
|
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } |
||||||
|
android.graphics.BitmapFactory.decodeFile(path, bounds) |
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null |
||||||
|
var sample = 1 |
||||||
|
while ((bounds.outWidth / sample) > MAX_DECODE_DIM || (bounds.outHeight / sample) > MAX_DECODE_DIM) sample *= 2 |
||||||
|
return PhotoUtil.decodeOriented(path, sample) |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun PhotoViewerScreen(filePath: String, onClose: () -> Unit) { |
||||||
|
val context = LocalContext.current |
||||||
|
var bmp by remember(filePath) { mutableStateOf<Bitmap?>(null) } |
||||||
|
|
||||||
|
/* ── immersive: hide system bars ── */ |
||||||
|
DisposableEffect(Unit) { |
||||||
|
val act = context.findActivity() |
||||||
|
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } |
||||||
|
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE |
||||||
|
controller?.hide(WindowInsetsCompat.Type.systemBars()) |
||||||
|
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) } |
||||||
|
} |
||||||
|
|
||||||
|
LaunchedEffect(filePath) { |
||||||
|
bmp = withContext(Dispatchers.IO) { decodeBitmap(filePath) } |
||||||
|
if (bmp == null) LogManager.addLog("ERROR", "PhotoViewer", "decode failed: $filePath") |
||||||
|
} |
||||||
|
|
||||||
|
Box( |
||||||
|
modifier = Modifier.fillMaxSize().background(Color.Black) |
||||||
|
) { |
||||||
|
bmp?.let { ZoomableImage(it, Modifier.fillMaxSize()) } |
||||||
|
|
||||||
|
/* close button (top-right) */ |
||||||
|
Row( |
||||||
|
modifier = Modifier.fillMaxWidth() |
||||||
|
.background(Color(0xAA000000)) |
||||||
|
.statusBarsPadding() |
||||||
|
.padding(horizontal = 4.dp), |
||||||
|
verticalAlignment = Alignment.CenterVertically, |
||||||
|
horizontalArrangement = Arrangement.End |
||||||
|
) { |
||||||
|
IconButton(onClick = onClose) { |
||||||
|
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
#include "photo_sender.h" |
||||||
|
#include "instance_lite.h" |
||||||
|
#include "../../../lib/debug_config.h" |
||||||
|
#include "../../../lib/mem.h" |
||||||
|
#include "../../../lib/u_async.h" |
||||||
|
#include "../../../src/chat/chat_core.h" |
||||||
|
#include <stdio.h> |
||||||
|
#include <stdlib.h> |
||||||
|
#include <string.h> |
||||||
|
#include <time.h> |
||||||
|
#include <sys/stat.h> |
||||||
|
#include <unistd.h> |
||||||
|
#include <libgen.h> |
||||||
|
|
||||||
|
int photo_send(const char* channel_id, const char* src_file_path, const char* db_path, |
||||||
|
int width, int height) { |
||||||
|
if (!channel_id || !src_file_path || !db_path) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "photo_send: invalid args"); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
|
||||||
|
struct UASYNC* ua = instance_lite_get_uasync(); |
||||||
|
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "photo_send: no uasync"); return -1; } |
||||||
|
|
||||||
|
struct stat st; memset(&st, 0, sizeof(st)); |
||||||
|
if (stat(src_file_path, &st) != 0 || st.st_size <= 0) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "photo_send: source not found or empty: %s", src_file_path); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
|
||||||
|
char path_copy[1024]; |
||||||
|
snprintf(path_copy, sizeof(path_copy), "%s", src_file_path); |
||||||
|
char* fname = basename(path_copy); |
||||||
|
char original_basename[256]; |
||||||
|
snprintf(original_basename, sizeof(original_basename), "%s", fname); |
||||||
|
char* dot = strrchr(fname, '.'); |
||||||
|
if (dot) *dot = '\0'; |
||||||
|
|
||||||
|
time_t t = time(NULL); |
||||||
|
struct tm tm_buf; localtime_r(&t, &tm_buf); |
||||||
|
char dt[32]; snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d", |
||||||
|
tm_buf.tm_year + 1900, tm_buf.tm_mon + 1, tm_buf.tm_mday, |
||||||
|
tm_buf.tm_hour, tm_buf.tm_min, tm_buf.tm_sec); |
||||||
|
int rnd = rand() & 0xFFFF; |
||||||
|
char suffix[8]; snprintf(suffix, sizeof(suffix), "%04x", rnd); |
||||||
|
|
||||||
|
/* ensure media dir exists */ |
||||||
|
char media_dir[1024]; |
||||||
|
snprintf(media_dir, sizeof(media_dir), "%s/media/%s", db_path, channel_id); |
||||||
|
{ |
||||||
|
char tmp[1024]; size_t off = 0; |
||||||
|
for (size_t i = 0; media_dir[i] && off < sizeof(tmp) - 1; i++) { |
||||||
|
tmp[off++] = media_dir[i]; |
||||||
|
if (media_dir[i] == '/' && off > 1) { tmp[off] = '\0'; mkdir(tmp, 0755); } |
||||||
|
} |
||||||
|
mkdir(tmp, 0755); |
||||||
|
} |
||||||
|
|
||||||
|
char media_file[1024]; |
||||||
|
snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.jpg", media_dir, dt, fname, suffix); |
||||||
|
|
||||||
|
size_t datalen = strlen(original_basename); |
||||||
|
struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + datalen + 1); |
||||||
|
if (!req) return -1; |
||||||
|
|
||||||
|
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id); |
||||||
|
snprintf(req->content_type, sizeof(req->content_type), "image/jpeg"); |
||||||
|
snprintf(req->media_src, sizeof(req->media_src), "%s", src_file_path); |
||||||
|
snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file); |
||||||
|
req->media_copy = 1; |
||||||
|
req->media_video = 0; |
||||||
|
req->width = width; |
||||||
|
req->height = height; |
||||||
|
req->data = (uint8_t*)(req + 1); |
||||||
|
memcpy(req->data, original_basename, datalen); |
||||||
|
req->data_len = (uint32_t)datalen; |
||||||
|
req->timestamp = 0; |
||||||
|
|
||||||
|
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "photo_send: ch=%s src=%s dst=%s name=%s size=%lld %dx%d", |
||||||
|
channel_id, src_file_path, media_file, original_basename, (long long)st.st_size, |
||||||
|
width, height); |
||||||
|
|
||||||
|
uasync_post(ua, chat_core_submit_trampoline, req); |
||||||
|
return 0; |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
#ifndef PHOTO_SENDER_H |
||||||
|
#define PHOTO_SENDER_H |
||||||
|
|
||||||
|
#ifdef __cplusplus |
||||||
|
extern "C" { |
||||||
|
#endif |
||||||
|
|
||||||
|
int photo_send(const char* channel_id, const char* src_file_path, const char* db_path, |
||||||
|
int width, int height); |
||||||
|
|
||||||
|
#ifdef __cplusplus |
||||||
|
} |
||||||
|
#endif |
||||||
|
|
||||||
|
#endif |
||||||
Loading…
Reference in new issue