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