From ca16b464041061074d45aa6efbf0d4aae7c983b2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 8 Sep 2026 20:02:40 +0300 Subject: [PATCH] =?UTF-8?q?chatgui-android:=20=D1=81=D1=8A=D1=91=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE=20=E2=80=94=20JPEG,=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B0=D1=82=D1=82=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BC,=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20?= =?UTF-8?q?=D1=81=20=D0=B7=D1=83=D0=BC=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/chat_msg.c | 5 + .../main/java/com/utun/chat/MainActivity.kt | 30 +- .../com/utun/chat/data/CameraLensManager.kt | 38 ++ .../java/com/utun/chat/data/ChatRepository.kt | 20 +- .../main/java/com/utun/chat/data/NativeLib.kt | 7 + .../main/java/com/utun/chat/data/PhotoUtil.kt | 52 +++ .../utun/chat/ui/components/MessageBubble.kt | 67 ++++ .../utun/chat/ui/components/ZoomableImage.kt | 73 ++++ .../com/utun/chat/ui/screens/ChatScreen.kt | 34 +- .../utun/chat/ui/screens/PhotoRecordScreen.kt | 347 ++++++++++++++++++ .../utun/chat/ui/screens/PhotoViewerScreen.kt | 83 +++++ .../utun/chat/ui/screens/VideoRecordScreen.kt | 5 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 12 + .../jni_bridge/android_jni_bridge.c | 18 + .../jni_bridge/android_jni_bridge.h | 4 + .../libutun_lite/photo_sender.c | 85 +++++ .../libutun_lite/photo_sender.h | 15 + .../libutun_lite/utun_sources.cmake | 1 + 18 files changed, 890 insertions(+), 6 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt create mode 100644 tools/chatgui-android/libutun_lite/photo_sender.c create mode 100644 tools/chatgui-android/libutun_lite/photo_sender.h diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index 882903f1..b656e171 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -155,6 +155,11 @@ static void on_media_registered(void* arg, int err, const struct media_index_res off += snprintf(full_data + off, fdcap - off, "|dur=%lld|w=%d|h=%d", (long long)mctx->duration_ms, mctx->width, mctx->height); } + /* для фото дописываем разрешение в конец */ + else if (mctx->content_type[0] && strncmp(mctx->content_type, "image/", 6) == 0 + && (mctx->width > 0 || mctx->height > 0)) { + off += snprintf(full_data + off, fdcap - off, "|w=%d|h=%d", mctx->width, mctx->height); + } size_t full_data_len = off; size_t json_cap = full_data_len + 256; diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 087a2c47..d31c6847 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -29,7 +29,7 @@ import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord } + enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -62,6 +62,7 @@ class MainActivity : ComponentActivity() { var joinAutoLink by remember { mutableStateOf(null) } var inviteChannelId by remember { mutableStateOf(null) } var videoMessage by remember { mutableStateOf(null) } + var photoMessage by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() { when (screen) { Screen.MemberList -> screen = Screen.Chat Screen.VideoRecord -> screen = Screen.Chat + Screen.PhotoRecord -> screen = Screen.Chat else -> screen = Screen.ChannelList } } @@ -87,6 +89,10 @@ class MainActivity : ComponentActivity() { videoMessage = null } + BackHandler(enabled = photoMessage != null) { + photoMessage = null + } + val connected = NativeLib.isReady() /* Subscribe native logs only when LogScreen is open */ @@ -127,7 +133,9 @@ class MainActivity : ComponentActivity() { onInvite = { showInvite(channel) }, onBack = { screen = Screen.ChannelList }, onPlayVideo = { msg -> videoMessage = msg }, - onRecordVideo = { screen = Screen.VideoRecord } + onViewPhoto = { msg -> photoMessage = msg }, + onRecordVideo = { screen = Screen.VideoRecord }, + onCapturePhoto = { screen = Screen.PhotoRecord } ) } } @@ -168,6 +176,18 @@ class MainActivity : ComponentActivity() { ) } } + Screen.PhotoRecord -> { + val channel = vm.currentChannel.collectAsState().value + if (channel != null) { + PhotoRecordScreen( + onSend = { path, width, height -> + vm.sendPhotoMessage(path, width, height) + screen = Screen.Chat + }, + onClose = { screen = Screen.Chat } + ) + } + } } SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding()) @@ -176,6 +196,12 @@ class MainActivity : ComponentActivity() { VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null }) } } + + photoMessage?.let { msg -> + if (msg.filePath.isNotEmpty()) { + PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null }) + } + } } /* Show join dialog after QR scan returns */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt index e7a81e56..9ef82fcd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt @@ -1,7 +1,9 @@ package com.utun.chat.data +import android.graphics.ImageFormat import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraMetadata +import android.util.Size import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.camera.core.CameraSelector import androidx.camera.lifecycle.ProcessCameraProvider @@ -9,6 +11,11 @@ import kotlin.math.abs import kotlin.math.round import kotlin.math.sqrt +data class PhotoResolution(val width: Int, val height: Int) { + val label: String get() = "${width}×${height}" + val area: Long get() = width.toLong() * height.toLong() +} + enum class LensType { SELFIE, MAIN, ULTRA_WIDE, MACRO, TELE } data class LensOption( @@ -106,4 +113,35 @@ object CameraLensManager { if (diag <= 0f) return focal return focal * 43.27f / diag } + + /* Варианты разрешения фото: 640×480 … native (≈4 штуки, зависят от камеры) */ + fun photoResolutions(provider: ProcessCameraProvider, cameraId: String): List { + val info = provider.availableCameraInfos.firstOrNull { + Camera2CameraInfo.from(it).cameraId == cameraId + } + if (info == null) { + LogManager.addLog("WARN", "Lens", "photoResolutions: camera $cameraId not found") + return emptyList() + } + val c2 = Camera2CameraInfo.from(info) + val map = c2.getCameraCharacteristic(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + val jpegSizes = map?.getOutputSizes(ImageFormat.JPEG) ?: emptyArray() + if (jpegSizes.isEmpty()) { + LogManager.addLog("WARN", "Lens", "photoResolutions: no JPEG sizes for $cameraId") + return emptyList() + } + + val native = jpegSizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: jpegSizes[0] + val candidates = listOf( + PhotoResolution(640, 480), + PhotoResolution(1280, 720), + PhotoResolution(1920, 1080), + PhotoResolution(native.width, native.height) + ) + val result = candidates + .filter { it.area <= native.width.toLong() * native.height.toLong() } + .distinctBy { it.label } + LogManager.addLog("INFO", "Lens", "photoResolutions $cameraId: ${result.joinToString { it.label }}") + return result + } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 0aa2d7c0..091a0ebb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -58,6 +58,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L val isOutgoing: Boolean = false, val contentType: String = "text/plain", val filePath: String = "", val waveform: List = emptyList(), val voiceDurationMs: Int = 0, val fileSize: Long = 0, + val imageWidth: Int = 0, val imageHeight: Int = 0, val downloadState: String = "" /* "", "downloading", "downloaded", "error" */) data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false, @@ -309,7 +310,7 @@ class ChatRepository { } catch (_: Exception) {} } - if (contentType == "application/octet-stream" || contentType.startsWith("video/")) { + if (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/")) { try { val pipeIdx = text.indexOf('|') if (pipeIdx > 0) { @@ -321,6 +322,19 @@ class ChatRepository { } catch (_: Exception) {} } + var imageWidth = 0 + var imageHeight = 0 + if (contentType.startsWith("image/")) { + try { + for (part in text.substringAfter('|').split("|")) { + when { + part.startsWith("w=") -> imageWidth = part.removePrefix("w=").toIntOrNull() ?: 0 + part.startsWith("h=") -> imageHeight = part.removePrefix("h=").toIntOrNull() ?: 0 + } + } + } catch (_: Exception) {} + } + list.add(Message( id = obj.getLong("id"), author = obj.getString("author"), @@ -332,6 +346,8 @@ class ChatRepository { waveform = waveform, voiceDurationMs = voiceDurationMs, fileSize = fileSize, + imageWidth = imageWidth, + imageHeight = imageHeight, downloadState = downloadState )) } @@ -343,6 +359,8 @@ class ChatRepository { fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath) fun sendVideo(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int) = NativeLib.videoSend(channelId, filePath, durationMs, width, height) + fun sendPhoto(channelId: String, filePath: String, width: Int, height: Int) = + NativeLib.photoSend(channelId, filePath, width, height) fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId) fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex) fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) = diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 80d1a864..da8b4370 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -123,6 +123,10 @@ object NativeLib { fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean = nativeVideoSend(channelId, filePath, durationMs, width, height) + /* ── Photo message ── */ + fun photoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean = + nativePhotoSend(channelId, filePath, width, height) + /* ── Voice playback ── */ fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo) fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf) @@ -194,6 +198,9 @@ object NativeLib { /* ── Video message JNI ── */ private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean + /* ── Photo message JNI ── */ + private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean + /* ── Voice playback JNI ── */ private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt new file mode 100644 index 00000000..ff5fc6aa --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt @@ -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 { + 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 + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index a3cf488d..516dcac6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.utun.chat.data.Message import com.utun.chat.data.NativeLib +import com.utun.chat.data.PhotoUtil import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -48,6 +49,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> }, onDownloadFile: (Message) -> Unit = {}, onOpenFile: (Message) -> Unit = {}, + onOpenImage: (Message) -> Unit = {}, onPlayVideo: (Message) -> Unit = {}) { val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) val textColor = if (message.isOutgoing) Color.White else Color.Black @@ -57,6 +59,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = val isVoice = message.contentType == "audio/opus" val isVideo = isVideoMessage(message) + val isImage = isImageMessage(message) val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain" val isActive = isVoice && playbackState.filePath == message.filePath @@ -77,6 +80,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = when { isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile) isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo) + isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage) isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile) else -> { Text(message.text, fontSize = 17.sp, color = textColor) @@ -253,6 +257,8 @@ private fun isVideoMessage(message: Message): Boolean { return false } +private fun isImageMessage(message: Message): Boolean = message.contentType.startsWith("image/") + private data class VideoMeta(val thumb: Bitmap?, val durationMs: Int) private fun extractVideoMeta(path: String): VideoMeta { @@ -337,6 +343,67 @@ private fun VideoBubble(message: Message, textColor: Color, } } +@Composable +private fun ImageBubble(message: Message, textColor: Color, + onDownload: (Message) -> Unit = {}, + onOpen: (Message) -> Unit = {}) { + val downloading = message.downloadState == "downloading" + val downloaded = message.filePath.isNotEmpty() + val isError = message.downloadState == "error" + + var bmp by remember(message.filePath) { mutableStateOf(null) } + + LaunchedEffect(message.filePath) { + if (message.filePath.isNotEmpty()) { + bmp = withContext(Dispatchers.IO) { + try { PhotoUtil.decodeOriented(message.filePath, 2) } catch (_: Exception) { null } + } + } + } + + val resText = if (message.imageWidth > 0 || message.imageHeight > 0) + "${message.imageWidth}×${message.imageHeight} · " else "" + + Column( + modifier = Modifier.clickable { + when { + downloading -> {} + downloaded -> onOpen(message) + else -> onDownload(message) + } + } + ) { + Box( + modifier = Modifier.size(width = 200.dp, height = 200.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF1E1E23)) + ) { + bmp?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) { + Text( + when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "" }, + fontSize = 18.sp, color = Color.White + ) + } + } + } + Spacer(Modifier.height(4.dp)) + Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold, + maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.height(2.dp)) + Text( + resText + formatFileSize(message.fileSize) + " · " + when { + downloading -> "Downloading..." + isError -> "Failed, tap to retry" + !downloaded -> "Tap to download" + else -> "Tap to open" + }, + fontSize = 12.sp, color = textColor.copy(alpha = 0.5f) + ) + } +} + private fun formatFileSize(bytes: Long): String { if (bytes <= 0) return "" if (bytes < 1024) return "${bytes}B" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt new file mode 100644 index 00000000..6a305a5c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt @@ -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) + } + } + ) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index af8392ba..7bc8cf62 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -18,6 +19,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.utun.chat.data.Channel import com.utun.chat.data.Message import com.utun.chat.data.NativeLib @@ -41,7 +43,9 @@ fun ChatScreen( onInvite: () -> Unit, onBack: () -> Unit, onPlayVideo: (Message) -> Unit = {}, - onRecordVideo: () -> Unit = {} + onViewPhoto: (Message) -> Unit = {}, + onRecordVideo: () -> Unit = {}, + onCapturePhoto: () -> Unit = {} ) { var text by remember { mutableStateOf("") } val isVideoMode by viewModel.isVideoMode.collectAsState() @@ -53,6 +57,9 @@ fun ChatScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + /* attach menu */ + var showAttachMenu by remember { mutableStateOf(false) } + /* pending attachment state */ var pendingAttachUri by remember { mutableStateOf(null) } var pendingAttachName by remember { mutableStateOf("") } @@ -136,6 +143,7 @@ fun ChatScreen( saveFileLauncher.launch(msg.text) /* suggest original filename */ } }, + onOpenImage = { msg -> onViewPhoto(msg) }, onPlayVideo = { msg -> onPlayVideo(msg) }) } item { Spacer(Modifier.height(8.dp)) } @@ -177,7 +185,7 @@ fun ChatScreen( viewModel.cancelVoiceRecording() recordingLevel = 0f }, - onAttachClicked = { filePickerLauncher.launch("*/*") }, + onAttachClicked = { showAttachMenu = true }, pendingAttachmentName = pendingAttachName, pendingAttachmentSize = pendingAttachSize, onClearAttachment = { @@ -190,5 +198,27 @@ fun ChatScreen( onVideoRecordOpen = onRecordVideo, modifier = Modifier.navigationBarsPadding() ) + + if (showAttachMenu) { + ModalBottomSheet(onDismissRequest = { showAttachMenu = false }) { + ListItem( + headlineContent = { Text("Выбрать файл") }, + leadingContent = { Text("\uD83D\uDCCE", fontSize = 20.sp) }, + modifier = Modifier.clickable { + showAttachMenu = false + filePickerLauncher.launch("*/*") + } + ) + ListItem( + headlineContent = { Text("Сфотографировать") }, + leadingContent = { Text("\uD83D\uDCF7", fontSize = 20.sp) }, + modifier = Modifier.clickable { + showAttachMenu = false + onCapturePhoto() + } + ) + Spacer(Modifier.height(16.dp)) + } + } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt new file mode 100644 index 00000000..13997745 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt @@ -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(null) } + var lenses by remember { mutableStateOf>(emptyList()) } + var selectedLens by remember { mutableStateOf(null) } + var resolutions by remember { mutableStateOf>(emptyList()) } + var selectedResolution by remember { mutableStateOf(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(null) } + var photoWidth by remember { mutableStateOf(0) } + var photoHeight by remember { mutableStateOf(0) } + var previewBitmap by remember { mutableStateOf(null) } + var boundCamera by remember { mutableStateOf(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 = 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) + ) + } + } + } + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt new file mode 100644 index 00000000..075f5f8c --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt @@ -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(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) + } + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt index de17f842..7b691fd3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt @@ -260,7 +260,10 @@ fun VideoRecordScreen( } else { AndroidView( factory = { ctx -> - PreviewView(ctx).also { pv -> preview.surfaceProvider = pv.surfaceProvider } + PreviewView(ctx).also { pv -> + pv.scaleType = PreviewView.ScaleType.FIT_CENTER + preview.surfaceProvider = pv.surfaceProvider + } }, modifier = Modifier.fillMaxSize() ) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index a67ec16f..92adbaaf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -643,6 +643,18 @@ class ChatViewModel : ViewModel() { } } + fun sendPhotoMessage(filePath: String, width: Int, height: Int) { + val r = repo ?: return + val ch = _currentChannel.value ?: return + LogManager.addLog("INFO", "VM", "sendPhotoMessage ch=${ch.id} ${width}x${height}") + r.sendPhoto(ch.id, filePath, width, height) + viewModelScope.launch { + delay(500) + refreshMessages(ch.id) + refreshChannels() + } + } + fun downloadAttachment(msg: Message) { val ch = _currentChannel.value ?: return NativeLib.attachmentDownload(ch.id, msg.id) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 104d68d3..feff8c00 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -14,6 +14,7 @@ #include "../libutun_lite/voice_recorder.h" #include "../libutun_lite/attachment_sender.h" #include "../libutun_lite/video_sender.h" +#include "../libutun_lite/photo_sender.h" #include "../libutun_lite/standby.h" #include "../../../lib/debug_config.h" #include "../../../lib/u_async.h" @@ -1093,6 +1094,11 @@ int utun_bridge_video_send(const char* channel_id, const char* file_path, const return video_send(channel_id, file_path, db_path, duration_ms, width, height); } +int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path, + int width, int height) { + return photo_send(channel_id, file_path, db_path, width, height); +} + /* ────────────────────────────────────────────────────────────────── * Voice playback (Opus decode) * ────────────────────────────────────────────────────────────────── */ @@ -1718,6 +1724,18 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVideoSend( return (rc == 0) ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativePhotoSend( + JNIEnv* env, jobject thiz, jstring channelId, jstring filePath, + jint width, jint height) { + (void)thiz; + const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); + const char* fp = (*env)->GetStringUTFChars(env, filePath, NULL); + int rc = utun_bridge_photo_send(ch, fp, g_db_path, (int)width, (int)height); + if (fp) (*env)->ReleaseStringUTFChars(env, filePath, fp); + if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch); + return (rc == 0) ? JNI_TRUE : JNI_FALSE; +} + /* ── Voice playback JNI ── */ JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeOpen( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index 87044207..bcb6df67 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -99,6 +99,10 @@ int utun_bridge_attachment_download(const char* channel_id, int64_t msg_id); int utun_bridge_video_send(const char* channel_id, const char* file_path, const char* db_path, int duration_ms, int width, int height); +/* Отправка фото (src — готовый jpeg, без транскода) */ +int utun_bridge_photo_send(const char* channel_id, const char* file_path, const char* db_path, + int width, int height); + /* ── Voice playback (Opus decode) ── */ void* utun_bridge_voice_decode_open(const char* file_path, diff --git a/tools/chatgui-android/libutun_lite/photo_sender.c b/tools/chatgui-android/libutun_lite/photo_sender.c new file mode 100644 index 00000000..ef950021 --- /dev/null +++ b/tools/chatgui-android/libutun_lite/photo_sender.c @@ -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 +#include +#include +#include +#include +#include +#include + +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; +} diff --git a/tools/chatgui-android/libutun_lite/photo_sender.h b/tools/chatgui-android/libutun_lite/photo_sender.h new file mode 100644 index 00000000..a5f6e9db --- /dev/null +++ b/tools/chatgui-android/libutun_lite/photo_sender.h @@ -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 diff --git a/tools/chatgui-android/libutun_lite/utun_sources.cmake b/tools/chatgui-android/libutun_lite/utun_sources.cmake index 8c2935d8..8229af2f 100644 --- a/tools/chatgui-android/libutun_lite/utun_sources.cmake +++ b/tools/chatgui-android/libutun_lite/utun_sources.cmake @@ -38,6 +38,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR) "${CONFIG_DIR}/voice_recorder.c" "${CONFIG_DIR}/attachment_sender.c" "${CONFIG_DIR}/video_sender.c" + "${CONFIG_DIR}/photo_sender.c" "${CONFIG_DIR}/standby.c" )