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:
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())
|
||||
|
||||
+66
-6
@@ -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)
|
||||
|
||||
+10
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user