Browse Source

chatgui-android: съёмка фото — JPEG, выбор разрешения, отправка аттачем, просмотр с зумом

v2
evgeny 3 weeks ago
parent
commit
ca16b46404
  1. 5
      src/chat/chat_msg.c
  2. 30
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  3. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt
  4. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  5. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  6. 52
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhotoUtil.kt
  7. 67
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  8. 73
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ZoomableImage.kt
  9. 34
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  10. 347
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt
  11. 83
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt
  12. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
  13. 12
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  14. 18
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  15. 4
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  16. 85
      tools/chatgui-android/libutun_lite/photo_sender.c
  17. 15
      tools/chatgui-android/libutun_lite/photo_sender.h
  18. 1
      tools/chatgui-android/libutun_lite/utun_sources.cmake

5
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;

30
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<String?>(null) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(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 */

38
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<PhotoResolution> {
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<Size>()
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
}
}

20
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<Float> = 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) =

7
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

52
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<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
}
}

67
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<Bitmap?>(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"

73
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)
}
}
)
}
}

34
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<Uri?>(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))
}
}
}
}

347
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<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)
)
}
}
}
}
}
}
}

83
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<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)
}
}
}
}

5
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()
)

12
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)

18
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(

4
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,

85
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 <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;
}

15
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

1
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"
)

Loading…
Cancel
Save