diff --git a/doc/message_links.md b/doc/message_links.md index 624a0e78..14d79ddf 100644 --- a/doc/message_links.md +++ b/doc/message_links.md @@ -22,6 +22,9 @@ Android и Qt распознают `http://`, `https://` и `utun://` в обы и полные адреса в исходном написании: Qt — `start/length`, Android — `start/end`, где end исключён. Единицы обоих API — UTF-16: `tools/chatgui/src/message_links.cpp`, Android `data/MessageLinks.kt`. +Удаление внешней пунктуации занимает линейное время: баланс трёх видов скобок +считается один раз, хвост сокращается по индексу без повторного копирования адреса. +Порт веб-адреса пустой либо десятичный от 0 до 65535; знак перед числом недопустим. Подсветка и нажатие используют эти диапазоны. Android подставляет emoji placeholders только в обычный текст между ссылками: emoji внутри адреса остаётся частью полного URL. Qt использует один QTextLayout для размеров, отрисовки, выделения и координат нажатия. @@ -38,11 +41,14 @@ Qt `QDesktopServices::openUrl`. На нажатии веб-адрес снова Общие случаи: `tools/chatgui/tests/message_links_cases.tsv`. Qt `test_invite_links` проверяет разбор, перенесённые веб-ссылки, смесь схем, нажатие, drag/copy и сокращённые приглашения; `test_emoji_playback` — жесты приглашений. -Android `tests/test_message_links.kt` проверяет тот же TSV на классах из APK-сборки: +Android `MessageLinksTest` проверяет тот же TSV через Gradle, некорректные authority +и длинный хвост пунктуации. Аналогичный тест длинного хвоста есть в Qt. +Оба разбора не открывают ссылки; некорректный пользовательский текст не создаёт логов. ```bash cd tools/chatgui-android -./build.sh noinstall -kotlinc tests/test_message_links.kt -classpath app/build/tmp/kotlin-classes/debug -d /tmp/message-links-tests.jar -kotlin -classpath /tmp/message-links-tests.jar:app/build/tmp/kotlin-classes/debug Test_message_linksKt ../chatgui/tests/message_links_cases.tsv +./gradlew :app:testDebugUnitTest --tests com.utun.chat.data.MessageLinksTest ``` + +`tests/test_message_links.kt` остаётся отдельным CLI-тестом того же TSV; +ему передаётся путь к файлу, а в classpath нужны собранные классы `MessageLinks`. diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 000edd51..c435a736 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -80,10 +80,14 @@ android { // Зависимость только между задачами: Android fixtures из JVM-модуля не входят в classpath приложения/APK. tasks.withType().configureEach { dependsOn(":router-tests:test") + val messageLinkCases = rootProject.file("../chatgui/tests/message_links_cases.tsv") + inputs.file(messageLinkCases) + systemProperty("utun.messageLinkCases", messageLinkCases.absolutePath) } dependencies { testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") // Реальный JSON для проверки границы JNI, без Android mock stubs. implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt index 3c4c78d2..41c4b37d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChannelReply.kt @@ -1,12 +1,23 @@ package com.utun.chat.data -/** Снимок выбранного ответа: revision отличает повторный выбор того же сообщения. */ +/** + * Выбранный оригинал фиксируется до асинхронной отправки; PM не поддерживает этот reply-контекст. + * @property channelId ID канала оригинала; отправитель проверяет совпадение с каналом назначения. + * @property message Снимок оригинала с глобальным messageId; вложенные списки не копируются. + * @property revision Счётчик выбора в ViewModel: завершение старой отправки не снимает повторно выбранный ответ. + */ data class ChannelReply(val channelId: String, val message: Message, val revision: Long) +/** + * Переводит 64 строчных hex-символа в новый массив 32 байтов для JNI; порядок байтов совпадает со строкой. + * Пустая строка даёт пустой массив (JNI заполнит отсутствующую ссылку нулями); 64 нуля тоже допустимы при конвертации. + * Для выбора реального оригинала/редактирования нужен [validMessageId]. Неверный непустой формат — IllegalArgumentException. + */ fun messageIdBytes(id: String): ByteArray { if (id.isEmpty()) return byteArrayOf() require(id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' }) { "Invalid message ID" } return ByteArray(32) { id.substring(it * 2, it * 2 + 2).toInt(16).toByte() } } +/** Только реальный глобальный ID: 32 байта в строчном hex; пустая строка и нулевой ID не обозначают сообщение. */ fun validMessageId(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' } && id.any { it != '0' } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 7a3aa0f6..7aba166f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -20,7 +20,6 @@ import java.net.InetAddress import java.net.NetworkInterface import java.io.File import java.io.IOException -import java.util.UUID private val Context.dataStore by preferencesDataStore("utun_config") @@ -633,54 +632,28 @@ class ConfigProvider(private val context: Context) { return "$dir/utun.cfg" } + /** Читает проверенные listen-секции; IP берёт с текущего интерфейса либо оставляет wildcard для отсутствующего. */ fun loadServersFromConfig(includeCommented: Boolean = false): List { 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() - try { - 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() - val commented = trimmed.startsWith("#") - val body = if (commented) trimmed.removePrefix("#").trim() else trimmed - if (body.startsWith("[") && body.endsWith("]")) { - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) - sectionOpen = false; sectionCommented = commented; addrPort = 0; proto = "tcp"; ifName = ""; family = "" - if (!body.startsWith("[server:")) continue - if (commented && !includeCommented) continue - val section = body.removeSurrounding("[", "]").removePrefix("server:").removeSuffix("_tcp").removeSuffix("_udp") - val lastUnderscore = section.lastIndexOf('_') - if (lastUnderscore > 0) { - ifName = section.substring(0, lastUnderscore) - family = section.substring(lastUnderscore + 1) - } - 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 (sectionOpen && body.startsWith("transport=")) { - proto = if (body.removePrefix("transport=") == "udp") "udp" else "tcp" + val saved = try { + file.bufferedReader().use { reader -> + ServerConfig.parse(reader.lineSequence(), includeCommented) { warning -> + LogManager.addLog("WARN", "CFG", warning) } } - if (sectionOpen && ifName.isNotEmpty() && addrPort > 0 && (!sectionCommented || includeCommented)) - raw.add(RawSrv(ifName, family, addrPort, proto)) } catch (e: Exception) { LogManager.addLog("ERROR", "CFG", "loadServersFromConfig failed path=${file.path}: ${e.message}") return emptyList() } - + if (saved.isEmpty()) return emptyList() val liveIps = getAvailableInterfaces() - return raw.map { rs -> - val isV6 = rs.family == "v6" + return saved.map { server -> + val isV6 = server.bindIp.contains(':') val realIp = liveIps.firstOrNull { (ifn, ip) -> - ifn == rs.ifName && (if (isV6) ip.contains(":") else !ip.contains(":")) + ifn == server.ifName && ip.contains(':') == isV6 }?.second - ServerEntry(UUID.randomUUID().toString(), rs.ifName, - realIp ?: if (isV6) "::" else "0.0.0.0", rs.protocol, rs.port) + server.copy(bindIp = realIp ?: server.bindIp) } } @@ -803,7 +776,7 @@ class ConfigProvider(private val context: Context) { sb.appendLine() } - /** Return all currently available network interface names (for dropdown) */ + /** Синхронный список (интерфейс, IP) для UI/конфига; ошибка и fallback на loopback видны в CFG-логе. */ fun getAvailableInterfaces(): List> = runBlocking { val list = mutableListOf>() try { @@ -821,8 +794,13 @@ class ConfigProvider(private val context: Context) { list.add(ni.name to ip) } } - } catch (_: Exception) {} - if (list.isEmpty()) list.add("lo" to "127.0.0.1") + } catch (e: Exception) { + LogManager.addLog("ERROR", "CFG", "getAvailableInterfaces failed: ${e.javaClass.simpleName}: ${e.message}") + } + if (list.isEmpty()) { + LogManager.addLog("WARN", "CFG", "getAvailableInterfaces: no usable addresses, using lo=127.0.0.1") + list.add("lo" to "127.0.0.1") + } list } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt index 877e3892..d83968a4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConversationTarget.kt @@ -1,9 +1,14 @@ package com.utun.chat.data -/** Адресат фиксируется до записи, выбора файла или фоновой подготовки. */ +/** + * Снимок адресата до записи, выбора файла и фоновой подготовки; смена экрана не меняет уже начатую отправку. + * @property id Десятичный ID канала/PM из модели, без префикса; класс не разбирает и не проверяет его. + */ sealed class ConversationTarget { abstract val id: String data class Channel(override val id: String) : ConversationTarget() + /** generation берётся вместе с ID беседы; после удаления старое поколение не может публиковать в заново открытую PM. */ data class Pm(override val id: String, val generation: Long) : ConversationTarget() + /** Target для общего voice/video API. Поколение PM не входит в строку и передаётся native отдельно. */ val nativeId: String get() = if (this is Pm) "dm:$id" else id } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt index 4d198104..cbd5053f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/InviteLink.kt @@ -1,7 +1,8 @@ package com.utun.chat.data -import com.utun.chat.data.LogManager - +/* Kotlin-представление invite: wire/base64 и допустимость полей проверяет общий C-кодек через NativeLib. + * Декодирование не запускает подключение. Для join передавать исходный link: из preview ссылка не собирается. + * Массивы моделей принадлежат получателю, copy() их не клонирует; после создания использовать только для чтения. */ const val INVITE_LINK_VERSION: Int = 0x03 const val INVITE_PUBKEY_SIZE: Int = 32 const val INVITE_SNAME_MAX: Int = 64 @@ -10,14 +11,32 @@ const val INVITE_PROTO_UDP: Int = 0x01 const val INVITE_PROTO_TCP: Int = 0x02 const val INVITE_PROTO_REALITY: Int = 0x04 // флаг reality-камуфляжа поверх TCP (reality-TCP = 0x06) +/** REALITY-снимок; has=true у непустого native-preview, byte-поля сравниваются по содержимому. + * shortId — 8 байт, serverPubkey — 32 байта, version — 3 байта; serverName — SNI до 63 UTF-8 байт. */ data class InviteReality( val has: Boolean, val shortId: ByteArray, val serverPubkey: ByteArray, val version: ByteArray, val serverName: String -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InviteReality) return false + return has == other.has && shortId.contentEquals(other.shortId) && serverPubkey.contentEquals(other.serverPubkey) && + version.contentEquals(other.version) && serverName == other.serverName + } + override fun hashCode(): Int { + var result = has.hashCode() + result = result * 31 + shortId.contentHashCode() + result = result * 31 + serverPubkey.contentHashCode() + result = result * 31 + version.contentHashCode() + return result * 31 + serverName.hashCode() + } +} +/** Адрес узла подключения: family=4/6, address — 4/16 сетевых байт; port=1..65535 в порядке байтов хоста. + * socketId=0..255, proto — UDP=1/TCP=2/TCP+REALITY=6; поля native-preview проверены общим C-кодеком. */ data class InviteAddr( val family: Int, // 4 or 6 val address: ByteArray, @@ -34,6 +53,9 @@ data class InviteAddr( override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId * 31 + proto } +/** Полный снимок приглашения; равенство учитывает все поля, включая реквизиты входа и исходную ссылку. + * channelId — 1..Long.MAX_VALUE; joinKey/nodeId хранят все биты uint64 в Long (для unsigned-вывода использовать toULong()). + * pubkey — 32 байта X25519 узла подключения, addrs — 1..32 его адресов. password — UTF-8 preview, link — исходная wire-ссылка. */ data class InviteData( val channelId: Long, val joinKey: Long, @@ -47,36 +69,53 @@ data class InviteData( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is InviteData) return false - return channelId == other.channelId && pubkey.contentEquals(other.pubkey) && - nodeId == other.nodeId && addrs == other.addrs + return channelId == other.channelId && joinKey == other.joinKey && pubkey.contentEquals(other.pubkey) && + nodeId == other.nodeId && addrs == other.addrs && password == other.password && reality == other.reality && link == other.link + } + override fun hashCode(): Int { + var result = channelId.hashCode() + result = result * 31 + joinKey.hashCode() + result = result * 31 + pubkey.contentHashCode() + result = result * 31 + nodeId.hashCode() + result = result * 31 + addrs.hashCode() + result = result * 31 + password.hashCode() + result = result * 31 + (reality?.hashCode() ?: 0) + return result * 31 + link.hashCode() } - override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode() } object InviteLink { - private const val PREFIX = "utun://" - + /** Возвращает собственный preview или null при отказе C-кодека/повреждённом JSON; ошибок подключения здесь нет. + * Загруженная libutun обязательна. JNI-ответ не логируется: содержит join key и пароль. */ fun decode(link: String): InviteData? { val encoded = NativeLib.decodeInvite(link) ?: return null return try { - fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val value = org.json.JSONObject(encoded) - val addresses = value.getJSONArray("addresses") - val reality = value.optJSONObject("reality")?.let { - InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), - bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) - } - InviteData(value.getLong("channel"), value.getString("key").toULong().toLong(), - bytes(value.getString("pubkey")), value.getLong("node"), - (0 until addresses.length()).map { i -> - val a = addresses.getJSONObject(i) - InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) - }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + fromNativePreview(encoded, link) } catch (e: Exception) { LogManager.addLog("ERROR", "INVITE", "invalid native invitation preview: ${e.javaClass.simpleName}"); null } } + /** Преобразует проверенный C-кодеком JSON JNI без повторного разбора wire; ошибки полей передаёт decode. + * key/node — десятичные строки uint64, остальные byte-поля — hex. Числовой JSON/Double для ID не допускается. */ + internal fun fromNativePreview(encoded: String, link: String): InviteData { + fun bytes(hex: String): ByteArray = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val value = org.json.JSONObject(encoded) + val joinKey = (value.get("key") as String).toULong().toLong() + val nodeId = (value.get("node") as String).toULong().toLong() + val addresses = value.getJSONArray("addresses") + val reality = value.optJSONObject("reality")?.let { + InviteReality(true, bytes(it.getString("short")), bytes(it.getString("pubkey")), + bytes(it.getString("version")), String(bytes(it.getString("name")), Charsets.UTF_8)) + } + return InviteData(value.getLong("channel"), joinKey, bytes(value.getString("pubkey")), nodeId, + (0 until addresses.length()).map { i -> + val a = addresses.getJSONObject(i) + InviteAddr(a.getInt("family"), bytes(a.getString("ip")), a.getInt("port"), a.getInt("socket"), a.getInt("proto")) + }, String(bytes(value.getString("password")), Charsets.UTF_8), reality, link) + } + + /** Диагностическая подпись адреса/сокета; IPv6 выводится hex без сжатия, строка не является endpoint для подключения. */ fun addrToString(addr: InviteAddr): String { val protoStr = when { (addr.proto and INVITE_PROTO_REALITY) != 0 -> "TCP+REALITY" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt index a4ca1da8..527e93d3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MessageLinks.kt @@ -1,33 +1,51 @@ package com.utun.chat.data +import java.net.URI import java.net.URL -/** Границы UTF-16 и полный адрес; разбор не меняет текст и не открывает ссылки. */ +/** Диапазон [start, end) в UTF-16 исходного сообщения и полный адрес без внешней пунктуации. */ data class MessageLink(val start: Int, val end: Int, val url: String) +/** Чистый разбор для подсветки: HTTP/HTTPS без учёта регистра и utun://; сеть и JNI не вызываются. */ object MessageLinks { private val pattern = Regex("utun://[A-Za-z0-9+/]+={0,2}|(?i:https?://)[^\\s\\p{Z}\\p{Cc}<>\"'`«»“”‘’]+") private val invalidEscape = Regex("%(?![0-9A-Fa-f]{2})") - /** Некорректные URL в пользовательском тексте остаются обычным текстом. */ + /** Проверяет полный HTTP/HTTPS URL; порт пустой либо десятичный 0..65535. Некорректный текст — обычный ввод, без логов. */ fun isWebUrl(url: String): Boolean { if (!pattern.matches(url) || invalidEscape.containsMatchIn(url)) return false return try { val parsed = URL(url) - (parsed.protocol == "http" || parsed.protocol == "https") && parsed.host.isNotEmpty() && parsed.port in -1..65535 - } catch (_: java.net.MalformedURLException) { false } + if ((parsed.protocol != "http" && parsed.protocol != "https") || parsed.host.isEmpty() || parsed.port !in -1..65535) return false + // URL допускает -1/+80 и недопустимые символы host; URI проверяет только authority, не ограничивая Unicode/[] в пути. + val authority = URI("//${parsed.authority}").rawAuthority?.substringAfterLast('@') ?: return false + val port = if (authority.startsWith("[")) authority.substringAfter(']').substringAfter(':', "") + else authority.substringAfter(':', "") + port.all { it in '0'..'9' } + } catch (_: java.net.MalformedURLException) { false } catch (_: java.net.URISyntaxException) { false } } + /** Возвращает непересекающиеся диапазоны по порядку; отделение хвоста занимает O(n), адрес копируется один раз. */ fun parse(text: String): List = pattern.findAll(text).mapNotNull { match -> var url = match.value if (!url.startsWith("utun://")) { - while (url.isNotEmpty()) { - val last = url.last() - val closing = ")]}".indexOf(last) - val unmatched = closing >= 0 && url.count { it == last } > url.count { it == "([{"[closing] } - if (last !in ".,;:!?" && !unmatched) break - url = url.dropLast(1) + val balance = IntArray(3) + for (char in url) { + val opening = "([{".indexOf(char) + val closing = ")]}".indexOf(char) + if (opening >= 0) balance[opening]-- + if (closing >= 0) balance[closing]++ } + var end = url.length + while (end > 0) { + val last = url[end - 1] + val closing = ")]}".indexOf(last) + val unmatched = closing >= 0 && balance[closing] > 0 + if (last !in ".,;:!?" && !unmatched) break + if (closing >= 0) balance[closing]-- + end-- + } + url = url.substring(0, end) if (!isWebUrl(url)) return@mapNotNull null } MessageLink(match.range.first, match.range.first + url.length, url) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt new file mode 100644 index 00000000..fb36cc83 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerConfig.kt @@ -0,0 +1,50 @@ +package com.utun.chat.data + +import java.util.UUID + +/** Кодек listen-секций ConfigProvider, без файловой системы/Android. Полученные wildcard IP вызывающий код заменяет текущими. */ +internal object ServerConfig { + private val sectionName = Regex("(.+)_(v4|v6)(?:_(tcp|udp))?") + + /** + * Читает [server:interface_v4/v6_tcp/udp]; без суффикса протокола по умолчанию TCP. + * transport имеет приоритет над суффиксом; порт 1..65535. Остальные секции/поля не относятся к этому кодеку. + * includeCommented восстанавливает полностью закомментированные секции. Неверная секция пропускается с warn. + */ + fun parse(lines: Sequence, includeCommented: Boolean, warn: (String) -> Unit): List { + val result = mutableListOf() + val fields = mutableMapOf() + var name: String? = null + var sectionCommented = false + + fun finishSection() { + val current = name ?: return + val match = sectionName.matchEntire(current) + if (match == null) { warn("saved server '$current': invalid interface/family/protocol section name"); return } + val protocol = fields["transport"] ?: match.groupValues[3].ifEmpty { "tcp" } + if (protocol != "tcp" && protocol != "udp") { warn("saved server '$current': unsupported transport"); return } + val port = fields["addr"]?.substringAfterLast(':', "")?.toIntOrNull() + if (port == null || port !in 1..65535) { warn("saved server '$current': invalid listen port parsed=$port, expected 1..65535"); return } + val ip = if (match.groupValues[2] == "v6") "::" else "0.0.0.0" + result.add(ServerEntry(UUID.randomUUID().toString(), match.groupValues[1], ip, protocol, port)) + } + + for (line in lines) { + val trimmed = line.trim() + val commented = trimmed.startsWith('#') + val body = if (commented) trimmed.removePrefix("#").trim() else trimmed + if (body.startsWith('[') && body.endsWith(']')) { + finishSection() + fields.clear() + sectionCommented = commented + name = if (body.startsWith("[server:") && (!commented || includeCommented)) + body.removeSurrounding("[", "]").removePrefix("server:") else null + } else if (name != null && commented == sectionCommented) { + val equals = body.indexOf('=') + if (equals > 0) fields[body.substring(0, equals).trim()] = body.substring(equals + 1).trim() + } + } + finishSection() + return result + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt index a137ba60..93d986c0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ServerEntry.kt @@ -2,12 +2,17 @@ package com.utun.chat.data import java.util.UUID -/** Протоколы автосокетов. Порты сохраняются отдельно для интерфейса, семейства IP и протокола. */ +/** Режим автосокетов для ConfigProvider; configValue сохраняется в настройках/INI, protocols задаёт порядок секций. */ enum class AutoSocketProtocols(val configValue: String, val protocols: List) { TCP("tcp", listOf("tcp")), UDP("udp", listOf("udp")), BOTH("tcp+udp", listOf("tcp", "udp")); + /** + * Для одного интерфейса и IP-литерала создаёт запись каждого выбранного протокола с новым UUID. + * Порт берётся из первой saved-записи с тем же socketKey (даже при смене IP), иначе выбирается из 2000..9999. + * saved не изменяется: неактивные интерфейсы/протоколы сохраняет вызывающий код. Сеть не вызывается, bind/проверки порта здесь нет. + */ fun createServers(ifName: String, bindIp: String, saved: List): List = protocols.map { protocol -> val key = Triple(ifName, bindIp.contains(":"), protocol) val port = saved.firstOrNull { it.socketKey == key }?.port ?: (2000..9999).random() @@ -15,13 +20,16 @@ enum class AutoSocketProtocols(val configValue: String, val protocols: List get() = Triple(ifName, bindIp.contains(":"), protocol) + /** Суффикс [server:...]; один сокет на интерфейс, семейство IP и протокол. */ val sectionName: String get() = "${ifName}_${if (bindIp.contains(":")) "v6" else "v4"}_$protocol" } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt new file mode 100644 index 00000000..0ad5ebfd --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ChannelReplyTest.kt @@ -0,0 +1,45 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +/** Контракт строкового глобального ID на границе Kotlin/JNI, включая отсутствие reply. */ +class ChannelReplyTest { + @Test fun everyByteValueKeepsItsBits() { + for (value in 0..255) { + val id = "%02x".format(value).repeat(32) + assertArrayEquals(ByteArray(32) { value.toByte() }, messageIdBytes(id)) + assertEquals(value != 0, validMessageId(id)) + } + } + + @Test fun byteOrderMatchesGlobalId() { + val id = "0123456789abcdef".repeat(4) + val expected = byteArrayOf(1, 0x23, 0x45, 0x67, 0x89.toByte(), 0xab.toByte(), 0xcd.toByte(), 0xef.toByte()) + assertArrayEquals(ByteArray(32) { expected[it % expected.size] }, messageIdBytes(id)) + } + + @Test fun absentReplyAndZeroIdAreDistinctEncodingsWithoutRealMessage() { + assertArrayEquals(byteArrayOf(), messageIdBytes("")) + assertArrayEquals(ByteArray(32), messageIdBytes("0".repeat(64))) + assertFalse(validMessageId("")) + assertFalse(validMessageId("0".repeat(64))) + assertTrue(validMessageId("0".repeat(63) + "1")) + } + + @Test fun malformedNonemptyIdsAreRejectedWithoutNormalization() { + for (id in listOf("1", "a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), + "a".repeat(64), " " + "a".repeat(63), "a".repeat(63) + "\n")) { + assertFalse(id, validMessageId(id)) + assertThrows(IllegalArgumentException::class.java) { messageIdBytes(id) } + } + } + + @Test fun returnedIdBytesBelongToCaller() { + val id = "ff".repeat(32) + val first = messageIdBytes(id) + val second = messageIdBytes(id) + first.fill(0) + assertArrayEquals(ByteArray(32) { -1 }, second) + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt new file mode 100644 index 00000000..d4ef9959 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/InviteLinkTest.kt @@ -0,0 +1,78 @@ +package com.utun.chat.data + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +/** Значения JNI проверяются как строки uint64; Android Number/Double не сохраняет весь диапазон ID. */ +class InviteLinkTest { + private fun reality() = InviteReality(true, ByteArray(8) { 1 }, ByteArray(32) { 2 }, byteArrayOf(1, 2, 3), "example.test") + private fun invite() = InviteData(71, 13, ByteArray(32) { 3 }, 19, + listOf(InviteAddr(4, byteArrayOf(127, 0, 0, 1), 9999, 7, INVITE_PROTO_TCP or INVITE_PROTO_REALITY)), + "password", reality(), "utun://original") + + private fun preview(node: String, key: String = "13") = """ + {"channel":71,"key":"$key","node":"$node","pubkey":"${"03".repeat(32)}","password":"70617373776f7264", + "reality":{"short":"${"01".repeat(8)}","pubkey":"${"02".repeat(32)}","version":"010203","name":"6578616d706c652e74657374"}, + "addresses":[{"family":4,"socket":7,"proto":6,"port":9999,"ip":"7f000001"}]} + """.trimIndent() + + @Test fun nativePreviewPreservesEveryNodeBit() { + for (node in listOf(1uL, Long.MAX_VALUE.toULong(), 1uL shl 63, 0xfedcba9876543210uL, ULong.MAX_VALUE)) { + val actual = InviteLink.fromNativePreview(preview(node.toString()), "utun://original") + assertEquals(node.toLong(), actual.nodeId) + assertEquals(node, actual.nodeId.toULong()) + } + } + + @Test fun joinKeyAndAllPreviewFieldsSurviveMapping() { + assertEquals(invite().copy(joinKey = -1), InviteLink.fromNativePreview(preview("19", ULong.MAX_VALUE.toString()), "utun://original")) + } + + @Test fun floatingPointNodeIsRejected() { + // Именно такой тип может получиться после разбора JSON-числа > Long.MAX_VALUE в Android. + val value = JSONObject(preview("19")).put("node", 18446744073709551615.0) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(value.toString(), "utun://original") } + } + + @Test fun uint64FieldsRequireStringsRegardlessOfTheirValue() { + for (field in listOf("key", "node")) for (value in listOf(19L, 19.0, true, JSONObject.NULL)) { + val json = JSONObject(preview("19")).put(field, value) + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(json.toString(), "utun://original") } + } + } + + @Test fun signedChannelRangeKeepsExactMaximumValue() { + val json = JSONObject(preview("19")).put("channel", Long.MAX_VALUE) + assertEquals(Long.MAX_VALUE, InviteLink.fromNativePreview(json.toString(), "utun://original").channelId) + } + + @Test fun unsignedNodeAndKeyRejectOutOfRangeStrings() { + for (bad in listOf("-1", "18446744073709551616", "1.5", "not-a-number")) { + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview(bad), "utun://original") } + assertThrows(Exception::class.java) { InviteLink.fromNativePreview(preview("19", bad), "utun://original") } + } + } + + @Test fun independentSnapshotsHaveStructuralEquality() { + val first = invite(); val second = invite() + assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode()) + assertEquals(first.reality, second.reality); assertEquals(first.reality.hashCode(), second.reality.hashCode()) + assertEquals(1, hashSetOf(first, second).size) + } + + @Test fun everyInvitationFieldParticipatesInEquality() { + val first = invite() + val changed = listOf(first.copy(channelId = 72), first.copy(joinKey = 14), first.copy(pubkey = ByteArray(32) { 4 }), + first.copy(nodeId = 20), first.copy(addrs = emptyList()), first.copy(password = "different"), + first.copy(reality = null), first.copy(link = "utun://different")) + changed.forEach { assertNotEquals(first, it) } + assertEquals(changed.size + 1, (changed + first).toSet().size) + } + + @Test fun everyRealityFieldParticipatesInEquality() { + val first = reality() + listOf(first.copy(has = false), first.copy(shortId = ByteArray(8)), first.copy(serverPubkey = ByteArray(32)), + first.copy(version = byteArrayOf(4, 5, 6)), first.copy(serverName = "other.test")).forEach { assertNotEquals(first, it) } + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt new file mode 100644 index 00000000..bab81c7f --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/MessageLinksTest.kt @@ -0,0 +1,73 @@ +package com.utun.chat.data + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** Общие с Qt адреса и диапазоны UTF-16; разбор длинного хвоста не задерживает UI. */ +class MessageLinksTest { + private fun assertLinks(text: String, expected: List) { + val links = MessageLinks.parse(text) + assertEquals(text.take(200), expected, links.map { it.url }) + var end = 0 + for (link in links) { + assertTrue(link.start >= end) + assertEquals(link.url, text.substring(link.start, link.end)) + end = link.end + } + } + + @Test fun sharedQtAndAndroidCases() { + val fixture = File(requireNotNull(System.getProperty("utun.messageLinkCases"))) + var count = 0 + fixture.forEachLine { line -> + if (line.isNotEmpty() && !line.startsWith("#")) { + val columns = line.split('\t') + assertLinks(columns.first().replace("\\n", "\n"), columns.drop(1)) + count++ + } + } + assertTrue("Expected shared link cases", count >= 15) + } + + @Test fun openingOnlyAcceptsCompleteWebUrls() { + for (url in listOf("http://example.com/", "HTTPS://пример.рф/путь", "https://[::1]:65535/", + "http://example.com:0/", "http://example.com:/", "http://example.com:00080/")) { + assertTrue(url, MessageLinks.isWebUrl(url)) + } + for (url in listOf("utun://abc+/==", "ftp://example.com/", "https://", "https://example.com/a b", "https://example.com/%GG")) { + assertFalse(url, MessageLinks.isWebUrl(url)) + } + } + + @Test fun malformedAuthorityIsNeitherAnnotatedNorOpened() { + for (url in listOf("http://example.com:-1/", "http://example.com:+80/", "http://example.com:-0/", "http://example.com\\foo/")) { + assertFalse(url, MessageLinks.isWebUrl(url)) + assertLinks(url, emptyList()) + } + } + + @Test fun bracketTypesHaveIndependentBalances() { + for (path in listOf("a(b)", "a[0]", "a{0}", "a([{}])", "a((b)", "a[b[0]")) { + val url = "https://example.com/$path" + assertLinks("✨ $url.,;:!?", listOf(url)) + } + assertLinks("https://example.com/a(b)))! next", listOf("https://example.com/a(b)")) + assertLinks("https://example.com/a[0]}).", listOf("https://example.com/a[0]")) + assertLinks("https://example.com/a)(b)", listOf("https://example.com/a)(b")) + } + + @Test(timeout = 5_000) fun longMixedTailKeepsOriginalRangesWithoutRepeatedCopies() { + val first = "https://example.com/a([{}])" + val second = "http://example.org/b" + val text = "👋 $first" + ")]},.!?".repeat(20_000) + " $second" + assertLinks(text, listOf(first, second)) + assertEquals(3, MessageLinks.parse(text).first().start) + } + + @Test fun textWithoutLinksStaysUnannotated() { + for (text in listOf("", "Обычный текст 👋", "www.example.com", "http:// https://?x=1")) { + assertLinks(text, emptyList()) + } + } +} diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt new file mode 100644 index 00000000..fa539eb0 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/data/ServerConfigTest.kt @@ -0,0 +1,81 @@ +package com.utun.chat.data + +import org.junit.Assert.* +import org.junit.Test + +/** Сохранённые порты проверяются до повторного использования в автосокетах; ошибки сохраняют остальные секции. */ +class ServerConfigTest { + private fun section(name: String, port: String, transport: String = "tcp") = + "[server:$name]\naddr=0.0.0.0:$port\ntransport=$transport\n" + + @Test fun roundTripIdentityUsesInterfaceFamilyAndTransport() { + val config = section("wlan_0_v4_tcp", "2001") + section("wlan_0_v4_udp", "2002", "udp") + + "[server:wlan_0_v6_tcp]\naddr=[::]:2003\ntransport=tcp\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf(2001, 2002, 2003), servers.map { it.port }) + assertEquals(listOf("wlan_0_v4_tcp", "wlan_0_v4_udp", "wlan_0_v6_tcp"), servers.map { it.sectionName }) + assertEquals(listOf("0.0.0.0", "0.0.0.0", "::"), servers.map { it.bindIp }) + assertEquals(listOf(2001, 2002), AutoSocketProtocols.BOTH.createServers("wlan_0", "192.0.2.1", servers).map { it.port }) + } + + @Test fun invalidSavedPortsAreSkippedBeforeAutoReuse() { + for (port in listOf("0", "-1", "65536", "2147483648", "invalid", "")) { + val warnings = mutableListOf() + val config = section("wlan0_v4_tcp", port) + section("other_v4_tcp", "3456") + val servers = ServerConfig.parse(config.lineSequence(), false, warnings::add) + assertEquals(listOf("other"), servers.map { it.ifName }) + assertEquals(listOf(3456), servers.map { it.port }) + assertEquals(1, warnings.size) + assertTrue(warnings.single(), warnings.single().contains("wlan0_v4_tcp")) + val recovered = AutoSocketProtocols.TCP.createServers("wlan0", "192.0.2.1", servers).single() + assertTrue(recovered.port in 2000..9999) + } + } + + @Test fun bothPortBoundariesArePreserved() { + val config = section("first_v4_tcp", "1") + section("last_v4_udp", "65535", "udp") + assertEquals(listOf(1, 65535), ServerConfig.parse(config.lineSequence(), false) { fail(it) }.map { it.port }) + } + + @Test fun commentedSectionsAreOnlyRestoredWhenRequested() { + val inactive = section("offline_v6_udp", "4567", "udp").lineSequence().filter { it.isNotEmpty() }.joinToString("\n") { "#$it" } + val config = section("live_v4_tcp", "2345") + inactive + val active = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + val all = ServerConfig.parse(config.lineSequence(), true) { fail(it) } + assertEquals(listOf("live"), active.map { it.ifName }) + assertEquals(listOf("live", "offline"), all.map { it.ifName }) + assertEquals("::", all.last().bindIp) + assertEquals("udp", all.last().protocol) + } + + @Test fun commentedFieldsDoNotOverrideActiveFields() { + val config = section("wlan0_v4_tcp", "2345") + "#addr=0.0.0.0:9999\n#transport=udp\n" + for (includeCommented in listOf(false, true)) { + val server = ServerConfig.parse(config.lineSequence(), includeCommented) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + } + + @Test fun unknownSectionsDoNotLeakFieldsIntoServer() { + val config = "[global]\naddr=0.0.0.0:9999\n" + section("wlan0_v4_tcp", "2345") + "[debug]\ntransport=udp\n" + val server = ServerConfig.parse(config.lineSequence(), false) { fail(it) }.single() + assertEquals(2345, server.port) + assertEquals("tcp", server.protocol) + } + + @Test fun invalidSectionAndTransportAreDiagnosed() { + val warnings = mutableListOf() + val config = section("wlan0_unknown_tcp", "2345") + section("wlan0_v4_tcp", "2345", "unknown") + + "[server:missing_v4_tcp]\ntransport=tcp\n" + assertTrue(ServerConfig.parse(config.lineSequence(), false, warnings::add).isEmpty()) + assertEquals(3, warnings.size) + } + + @Test fun transportFieldWinsAndSuffixSuppliesDefault() { + val config = section("wlan0_v4_tcp", "2345", "udp") + "[server:wlan0_v6_udp]\naddr=[::]:3456\n" + + "[server:old_v4]\naddr=0.0.0.0:4567\n" + val servers = ServerConfig.parse(config.lineSequence(), false) { fail(it) } + assertEquals(listOf("udp", "udp", "tcp"), servers.map { it.protocol }) + } +} diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index cbbd4feb..62c738c5 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1912,7 +1912,8 @@ JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel( (*env)->ReleaseStringUTFChars(env, link, text); return result; } -/* Представление результата общего C-кодека для предпросмотра Kotlin. */ +/* Представление результата общего C-кодека для предпросмотра Kotlin. + * uint64 key/node передаются десятичными строками: числовой JSON может потерять биты при преобразовании в Double/Long. */ static int invite_hex_json(struct strbuf* out, const uint8_t* bytes, size_t len) { char hex[257]; if (len > 128) return -1; @@ -1930,7 +1931,7 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeDecodeInvite( (*env)->ReleaseStringUTFChars(env, link, text); if (result) return NULL; struct strbuf out = strbuf_new(); - if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":%llu,\"pubkey\":", + if (strbuf_addf(&out, "{\"channel\":%llu,\"key\":\"%llu\",\"node\":\"%llu\",\"pubkey\":", (unsigned long long)data.channelId, (unsigned long long)data.join_key, (unsigned long long)data.nodeId) < 0 || invite_hex_json(&out, data.pubkey, 32) < 0 || strbuf_addf(&out, ",\"password\":") < 0 || invite_hex_json(&out, (const uint8_t*)data.password, data.password_len) < 0) goto failed; diff --git a/tools/chatgui/src/message_links.cpp b/tools/chatgui/src/message_links.cpp index cbe988e5..e74a6b0f 100644 --- a/tools/chatgui/src/message_links.cpp +++ b/tools/chatgui/src/message_links.cpp @@ -12,13 +12,24 @@ QVector messageLinks(const QString& text) { const auto match = matches.next(); QString url = match.captured(); if (!url.startsWith(QStringLiteral("utun://"))) { - while (!url.isEmpty()) { - const QChar last = url.back(); - const int closing = QStringLiteral(")]}").indexOf(last); - const bool unmatched = closing >= 0 && url.count(last) > url.count(QStringLiteral("([{").at(closing)); - if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; - url.chop(1); + // Один подсчёт и один проход по хвосту: повторный count() на каждой скобке даёт O(n²). + qsizetype balance[3] = {}; + for (const QChar ch : url) { + const int opening = QStringLiteral("([{").indexOf(ch); + const int closing = QStringLiteral(")]}").indexOf(ch); + if (opening >= 0) balance[opening]--; + if (closing >= 0) balance[closing]++; } + auto end = url.size(); + while (end > 0) { + const QChar last = url.at(end - 1); + const int closing = QStringLiteral(")]}").indexOf(last); + const bool unmatched = closing >= 0 && balance[closing] > 0; + if (!QStringLiteral(".,;:!?").contains(last) && !unmatched) break; + if (closing >= 0) balance[closing]--; + end--; + } + url.truncate(end); const QUrl parsed(url, QUrl::StrictMode); if (!parsed.isValid() || parsed.host().isEmpty()) continue; } diff --git a/tools/chatgui/tests/test_invite_links.cpp b/tools/chatgui/tests/test_invite_links.cpp index 2ebec6a6..b34eb6bf 100644 --- a/tools/chatgui/tests/test_invite_links.cpp +++ b/tools/chatgui/tests/test_invite_links.cpp @@ -259,6 +259,22 @@ private slots: QVERIFY(count >= 15); } + void longMessageLinkTail() { + const QString first = QStringLiteral("https://example.com/a([0])"); + const QString second = QStringLiteral("http://example.org/b"); + const QString text = QString::fromUtf8("👋 ") + first + QStringLiteral(")]},.!?").repeated(20000) + ' ' + second; + QElapsedTimer timer; timer.start(); + const auto links = messageLinks(text); + const auto elapsed = timer.elapsed(); + qInfo() << "Message links:" << text.size() << "UTF-16 units parsed in" << elapsed << "ms"; + QCOMPARE(links.size(), 2); + QCOMPARE(links.first().url, first); + QCOMPARE(links.first().start, 3); + QCOMPARE(links.last().url, second); + QCOMPARE(text.mid(links.last().start, links.last().length), second); + QVERIFY2(elapsed < 5000, "Message link parsing exceeded the UI regression guard"); + } + void webLinksUsePaintedCoordinatesAndDragDoesNotOpen() { const QString first = QStringLiteral("https://пример.рф/путь?") + QString(160, 'a') + QStringLiteral("=1#fragment"); const QString second = QStringLiteral("http://example.org/a(b)?x=2");