Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
799a71d361
16 files changed
+515
-91
No files matched your search
+10
-4
@@ -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`.
|
||||
@@ -80,10 +80,14 @@ android {
|
||||
// Зависимость только между задачами: Android fixtures из JVM-модуля не входят в classpath приложения/APK.
|
||||
tasks.withType<org.gradle.api.tasks.testing.Test>().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")
|
||||
|
||||
@@ -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' }
|
||||
@@ -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<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 {
|
||||
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<Pair<String, String>> = runBlocking {
|
||||
val list = mutableListOf<Pair<String, String>>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<MessageLink> = 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)
|
||||
|
||||
@@ -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<String>, includeCommented: Boolean, warn: (String) -> Unit): List<ServerEntry> {
|
||||
val result = mutableListOf<ServerEntry>()
|
||||
val fields = mutableMapOf<String, String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String>) {
|
||||
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<ServerEntry>): List<ServerEntry> = 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<Stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Описание listen-сокета для генератора конфига; само не открывает сокет и не проверяет входные поля. */
|
||||
data class ServerEntry(
|
||||
val id: String, // UUID
|
||||
val ifName: String, // wlan0, rmnet0, ...
|
||||
val bindIp: String, // IPv4 или IPv6 адрес
|
||||
val protocol: String, // "tcp" or "udp"
|
||||
val port: Int
|
||||
val id: String, // UUID записи UI; при повторном обнаружении создаётся заново.
|
||||
val ifName: String, // Имя интерфейса: wlan0, rmnet0 и т. п.
|
||||
val bindIp: String, // IP-литерал или wildcard 0.0.0.0/::; ':' определяет семейство IPv6.
|
||||
val protocol: String, // tcp или udp в нижнем регистре.
|
||||
val port: Int // Listen-порт; допустимость обеспечивает вызывающий код/парсер конфига.
|
||||
) {
|
||||
/** Устойчивый ключ сохранения порта; не зависит от UUID и конкретного адреса внутри семейства IP. */
|
||||
val socketKey: Triple<String, Boolean, String> get() = Triple(ifName, bindIp.contains(":"), protocol)
|
||||
/** Суффикс [server:...]; один сокет на интерфейс, семейство IP и протокол. */
|
||||
val sectionName: String get() = "${ifName}_${if (bindIp.contains(":")) "v6" else "v4"}_$protocol"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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<String>) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -12,13 +12,24 @@ QVector<MessageLink> 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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user