chatgui/chatgui-android: просмотр изображений с зумом + инфо о медиа

- chatgui: просмотрщик изображений (ImageViewerWindow) с зумом/поворотом
- chatgui: отправка картинок как image с определением разрешения и типа
- chatgui: у видео-пузыря строка info (res·size·bitrate) и бейдж только длительность
- chatgui: контекстное меню медиа (сохранить в файл/скачать)
- chatgui-android: большие emoji-only сообщения, битрейт/разрешение у видео
- chatgui-android: сохранение неактивных интерфейсов как закомментированные секции
- chatgui-android: отправка картинок по расширению (jpg/png/gif/bmp/webp)
- photo_sender: content-type и расширение по типу файла
This commit is contained in:
evgeny committed 2026-09-09 16:18:26 +03:00
1 parent ca16b46404
commit c474c37284
17 files changed
+854 -66

No files matched your search

@@ -59,6 +59,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
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 videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */)
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
@@ -335,6 +336,21 @@ class ChatRepository {
} catch (_: Exception) {}
}
var videoWidth = 0
var videoHeight = 0
var videoDurationMs = 0
if (contentType.startsWith("video/")) {
try {
for (part in text.substringAfter('|').split("|")) {
when {
part.startsWith("w=") -> videoWidth = part.removePrefix("w=").toIntOrNull() ?: 0
part.startsWith("h=") -> videoHeight = part.removePrefix("h=").toIntOrNull() ?: 0
part.startsWith("dur=") -> videoDurationMs = part.removePrefix("dur=").toIntOrNull() ?: 0
}
}
} catch (_: Exception) {}
}
list.add(Message(
id = obj.getLong("id"),
author = obj.getString("author"),
@@ -348,6 +364,9 @@ class ChatRepository {
fileSize = fileSize,
imageWidth = imageWidth,
imageHeight = imageHeight,
videoWidth = videoWidth,
videoHeight = videoHeight,
videoDurationMs = videoDurationMs,
downloadState = downloadState
))
}
@@ -265,35 +265,39 @@ class ConfigProvider(private val context: Context) {
return "$dir/utun.cfg"
}
fun loadServersFromConfig(): List<ServerEntry> {
fun loadServersFromConfig(includeCommented: Boolean = false): List<ServerEntry> {
val file = File(configFilePath())
if (!file.exists()) return emptyList()
data class RawSrv(val ifName: String, val family: String, val port: Int, val protocol: String)
val raw = mutableListOf<RawSrv>()
try {
val lines = file.readLines()
var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp"; var sectionOpen = false
for (line in lines) {
var ifName = ""; var family = ""; var addrPort = 0; var proto = "tcp"
var sectionOpen = false; var sectionCommented = false
for (line in file.readLines()) {
val trimmed = line.trim()
if (trimmed.startsWith("[server:") && trimmed.endsWith("]")) {
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0)
val commented = trimmed.startsWith("#")
val body = if (commented) trimmed.removePrefix("#").trim() else trimmed
if (body.startsWith("[server:") && body.endsWith("]")) {
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented))
raw.add(RawSrv(ifName, family, addrPort, proto))
val section = trimmed.removeSurrounding("[", "]").removePrefix("server:")
sectionOpen = false; sectionCommented = commented; addrPort = 0; proto = "tcp"; ifName = ""; family = ""
if (commented && !includeCommented) continue
val section = body.removeSurrounding("[", "]").removePrefix("server:")
val lastUnderscore = section.lastIndexOf('_')
if (lastUnderscore > 0) {
ifName = section.substring(0, lastUnderscore)
family = section.substring(lastUnderscore + 1)
} else { ifName = ""; family = "" }
addrPort = 0; proto = "tcp"; sectionOpen = true
} else if (trimmed.startsWith("addr=")) {
val addr = trimmed.removePrefix("addr=")
}
sectionOpen = true
} else if (sectionOpen && body.startsWith("addr=")) {
val addr = body.removePrefix("addr=")
val colonIdx = addr.lastIndexOf(':')
if (colonIdx >= 0) addrPort = addr.substring(colonIdx + 1).toIntOrNull() ?: 0
} else if (trimmed.startsWith("transport=")) {
proto = if (trimmed.removePrefix("transport=") == "udp") "udp" else "tcp"
} else if (sectionOpen && body.startsWith("transport=")) {
proto = if (body.removePrefix("transport=") == "udp") "udp" else "tcp"
}
}
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0)
if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented))
raw.add(RawSrv(ifName, family, addrPort, proto))
} catch (_: Exception) { return emptyList() }
@@ -308,15 +312,15 @@ class ConfigProvider(private val context: Context) {
}
}
fun saveServersToConfig(servers: List<ServerEntry>) {
val text = buildConfigFromServers(servers)
fun saveServersToConfig(servers: List<ServerEntry>, commented: List<ServerEntry> = emptyList()) {
val text = buildConfigFromServers(servers, commented)
File(configFilePath()).writeText(text)
LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} servers written" +
servers.joinToString(", ") { "${it.ifName}=${it.protocol}" })
LogManager.addLog("INFO", "CFG", "saveServersToConfig: ${servers.size} active, ${commented.size} commented" +
servers.joinToString(", ") { "${it.ifName}=${it.protocol}:${it.port}" })
}
/** Build complete INI text from server list (without touching config file) */
private fun buildConfigFromServers(servers: List<ServerEntry>): String {
private fun buildConfigFromServers(servers: List<ServerEntry>, commented: List<ServerEntry> = emptyList()): String {
val pubKey = getString("node.public_key") ?: ""
val privKey = getString("node.private_key") ?: ""
val nodeName = getString("node.name") ?: getDeviceName()
@@ -330,15 +334,8 @@ class ConfigProvider(private val context: Context) {
appendLine("db_sync_enabled=1")
appendLine("chatserver_enabled=1")
appendLine()
for (srv in servers) {
val family = if (srv.bindIp.contains(":")) "v6" else "v4"
appendLine("[server:${srv.ifName}_$family]")
appendLine("addr=${if (family == "v6") "[::]" else srv.bindIp.ifBlank { "0.0.0.0" }}:${srv.port}")
appendLine("type=public")
appendLine("transport=${srv.protocol}")
if (family == "v6") appendLine("netif=${srv.ifName}")
appendLine()
}
for (srv in servers) appendServerSection(this, srv, false)
for (srv in commented) appendServerSection(this, srv, true)
appendLine("[allowed_keys]")
appendLine("allow_all=yes")
appendLine()
@@ -386,6 +383,19 @@ class ConfigProvider(private val context: Context) {
}
}
/** Append one [server:...] section; comment=true prefixes each line with '#'. */
private fun appendServerSection(sb: StringBuilder, srv: ServerEntry, comment: Boolean) {
val family = if (srv.bindIp.contains(":")) "v6" else "v4"
val p = if (comment) "#" else ""
val addr = if (family == "v6") "[::]" else srv.bindIp.ifBlank { "0.0.0.0" }
sb.appendLine("${p}[server:${srv.ifName}_$family]")
sb.appendLine("${p}addr=$addr:${srv.port}")
sb.appendLine("${p}type=public")
sb.appendLine("${p}transport=${srv.protocol}")
if (family == "v6") sb.appendLine("${p}netif=${srv.ifName}")
sb.appendLine()
}
/** Return all currently available network interface names (for dropdown) */
fun getAvailableInterfaces(): List<Pair<String, String>> = runBlocking {
val list = mutableListOf<Pair<String, String>>()
@@ -413,11 +423,17 @@ class ConfigProvider(private val context: Context) {
fun buildConfigText(): String {
if (getConnectionMode() == "auto") {
val cfgFile = File(configFilePath())
val saved = if (cfgFile.exists()) loadServersFromConfig() else emptyList()
val saved = if (cfgFile.exists()) loadServersFromConfig(includeCommented = true) else emptyList()
val detected = runBlocking { detectInterfaces(saved) }
LogManager.addLog("INFO", "CFG", "buildConfigText: auto mode, detected ${detected.size} interfaces" +
detected.joinToString(", ") { "${it.ifName}=${it.protocol}" })
saveServersToConfig(detected)
detected.joinToString(", ") { "${it.ifName}=${it.protocol}:${it.port}" })
val upKeys = detected.map { it.ifName to it.bindIp.contains(":") }.toSet()
val down = saved.filter { (it.ifName to it.bindIp.contains(":")) !in upKeys }
if (down.isNotEmpty()) {
LogManager.addLog("INFO", "CFG", "buildConfigText: ${down.size} interfaces down, port preserved" +
down.joinToString(", ") { "${it.ifName}=${it.port}" })
}
saveServersToConfig(detected, down)
return buildConfigFromServers(detected)
}
val cfgFile = File(configFilePath())
@@ -3,6 +3,7 @@ package com.utun.chat.ui.components
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.icu.text.BreakIterator
import android.media.MediaMetadataRetriever
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -43,6 +44,36 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private fun isEmojiCodePoint(cp: Int): Boolean =
(cp in 0x1F000..0x1FAFF) ||
(cp in 0x2600..0x27BF) ||
(cp in 0x2300..0x23FF) ||
(cp in 0x2B00..0x2BFF) ||
(cp in 0xFE00..0xFE0F) ||
cp == 0x200D || cp == 0x20E3
private fun detectEmojiOnly(text: String): Int {
val t = text.trim()
if (t.isEmpty()) return 0
val it = BreakIterator.getCharacterInstance(java.util.Locale.US)
it.setText(t)
var count = 0
var start = it.first()
var end = it.next()
while (end != BreakIterator.DONE) {
val cluster = t.substring(start, end)
if (!cluster.isBlank()) {
val allEmoji = cluster.codePoints().allMatch { cp -> isEmojiCodePoint(cp) }
if (!allEmoji) return 0
count++
if (count > 3) return 0
}
start = end
end = it.next()
}
return if (count in 1..3) count else 0
}
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
@@ -63,6 +94,21 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
val isActive = isVoice && playbackState.filePath == message.filePath
val emojiCount = if (isVoice || isVideo || isImage || isMedia) 0 else detectEmojiOnly(message.text)
if (emojiCount > 0) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
) {
Text(
message.text.trim(),
fontSize = if (emojiCount == 1) 100.sp else 51.sp,
color = textColor
)
}
return
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
@@ -332,12 +378,20 @@ private fun VideoBubble(message: Message, textColor: Color,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(2.dp))
Text(
"${formatFileSize(message.fileSize)} · " + when {
downloading -> "Downloading..."
isError -> "Failed, tap to retry"
!downloaded -> "Tap to download"
else -> "Tap to play"
},
buildString {
if (message.videoWidth > 0 && message.videoHeight > 0)
append("${message.videoWidth}×${message.videoHeight} · ")
val sz = formatFileSize(message.fileSize)
if (sz.isNotEmpty()) append("$sz · ")
if (message.fileSize > 0 && message.videoDurationMs > 0)
append("${formatBitrate(message.fileSize, message.videoDurationMs)} · ")
append(when {
downloading -> "Downloading..."
isError -> "Failed, tap to retry"
!downloaded -> "Tap to download"
else -> "Tap to play"
})
}.trimEnd(),
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f)
)
}
@@ -415,6 +469,12 @@ private fun formatFileSize(bytes: Long): String {
return "%.1fGB".format(gb)
}
private fun formatBitrate(bytes: Long, durMs: Int): String {
if (bytes <= 0 || durMs <= 0) return ""
val kbps = bytes * 8 / durMs
return if (kbps >= 1000) "%.1fMbps".format(kbps / 1000.0) else "${kbps}kbps"
}
private fun formatDurationMs(ms: Int): String {
val secs = ms / 1000
return "%d:%02d".format(secs / 60, secs % 60)
@@ -17,9 +17,12 @@ 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)
private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float, iw: Float, ih: Float): Offset {
val fit = minOf(cW / iw, cH / ih)
val dw = iw * fit * scale
val dh = ih * fit * scale
val mx = ((dw - cW) / 2f).coerceAtLeast(0f)
val my = ((dh - cH) / 2f).coerceAtLeast(0f)
return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my))
}
@@ -32,9 +35,11 @@ fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: C
val density = LocalDensity.current
val cW = with(density) { maxWidth.toPx() }
val cH = with(density) { maxHeight.toPx() }
val iw = bitmap.width.toFloat()
val ih = bitmap.height.toFloat()
LaunchedEffect(cW, cH) {
zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH)
zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH, iw, ih)
}
Image(
@@ -65,7 +70,7 @@ fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: C
zoomOffset.y * z + rel.y * (1f - z) + pan.y
)
zoomScale = new
zoomOffset = clampOffset(new, raw, cW, cH)
zoomOffset = clampOffset(new, raw, cW, cH, iw, ih)
}
}
)
@@ -1,6 +1,7 @@
package com.utun.chat.viewmodel
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.util.Log
@@ -19,6 +20,8 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
class ChatViewModel : ViewModel() {
data class PlaybackState(
@@ -608,11 +611,33 @@ class ChatViewModel : ViewModel() {
fun sendAttachment(filePath: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
r.sendAttachment(ch.id, filePath)
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
val ext = filePath.substringAfterLast('.', "").lowercase()
if (ext in IMAGE_EXTENSIONS) {
viewModelScope.launch {
val dims = withContext(Dispatchers.IO) {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(filePath, opts)
opts.outWidth to opts.outHeight
}
val (w, h) = dims
if (w > 0 && h > 0) {
LogManager.addLog("INFO", "VM", "sendAttachment(image) ch=${ch.id} ext=$ext ${w}x${h}")
r.sendPhoto(ch.id, filePath, w, h)
} else {
LogManager.addLog("WARN", "VM", "image decode failed, sent as file: $filePath")
r.sendAttachment(ch.id, filePath)
}
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
} else {
r.sendAttachment(ch.id, filePath)
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
}
}