1. 修复真机崩溃:消息 ID 唯一化(LazyColumn 重复 key)+ 消息列表滚动先等布局完成(layout state not idle)
2. 新增 BBCode 解析:服务器欢迎消息/频道描述支持 b/i/u/s/color/size/url,链接可点击,病态输入不崩溃 3. 语音卡成员长按波纹圆角 4. 版本号提升至 1.0.10(versionCode 10)
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.tsmobile.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 9
|
||||
versionName = "1.0.9"
|
||||
versionCode = 10
|
||||
versionName = "1.0.10"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.tsmobile.app.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* 轻量 BBCode → AnnotatedString 解析器,用于服务器欢迎消息 / 频道描述等富文本。
|
||||
*
|
||||
* 支持标签:
|
||||
* - [b] [i] [u] [s] [color=#RRGGBB|name] [size=N] [url=http://...][url]
|
||||
*
|
||||
* 不支持(原样保留内容、忽略标签):[img] [list] [table] 等。
|
||||
* 未知标签会被安全忽略,不会因为畸形 BBCode 崩溃。
|
||||
*/
|
||||
object Bbcode {
|
||||
|
||||
private data class RunStyle(
|
||||
val bold: Boolean = false,
|
||||
val italic: Boolean = false,
|
||||
val underline: Boolean = false,
|
||||
val strike: Boolean = false,
|
||||
val color: Color? = null,
|
||||
val size: TextUnit? = null,
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
private val tagRegex = Regex("""\[/?([A-Za-z]+)(?:=([^\]]*))?]""")
|
||||
|
||||
/** 链接默认颜色(Material 蓝) */
|
||||
private val linkColor = Color(0xFF1A73E8)
|
||||
|
||||
fun parse(raw: String): AnnotatedString {
|
||||
return try {
|
||||
parseInternal(raw)
|
||||
} catch (e: Exception) {
|
||||
// BBCode 解析绝不应因畸形输入让 App 崩溃:任何异常都退化为纯文本
|
||||
Log.d("Bbcode", "parse failed, raw=${raw.take(120)}", e)
|
||||
AnnotatedString(raw)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseInternal(raw: String): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val stack = ArrayDeque<RunStyle>()
|
||||
var current = RunStyle()
|
||||
|
||||
var i = 0
|
||||
while (i < raw.length) {
|
||||
if (raw[i] == '[') {
|
||||
val m = tagRegex.find(raw, i)
|
||||
if (m != null && m.range.first == i) {
|
||||
val isClose = m.value.getOrNull(1) == '/'
|
||||
// 用 getOrNull 防 Kotlin Regex groupValues 越界(No group N)导致崩溃
|
||||
val tag = m.groupValues.getOrNull(1)?.lowercase() ?: ""
|
||||
val value = m.groupValues.getOrNull(2)?.trim()?.ifBlank { null }
|
||||
|
||||
if (isClose) {
|
||||
current = stack.removeLastOrNull() ?: current
|
||||
} else {
|
||||
// [br] 作为换行处理(无闭合标签)
|
||||
if (tag == "br") {
|
||||
builder.append("\n")
|
||||
i = m.range.last + 1
|
||||
continue
|
||||
}
|
||||
stack.addLast(current)
|
||||
current = when (tag) {
|
||||
"b" -> current.copy(bold = true)
|
||||
"i" -> current.copy(italic = true)
|
||||
"u" -> current.copy(underline = true)
|
||||
"s" -> current.copy(strike = true)
|
||||
"color" -> current.copy(color = parseColor(value))
|
||||
"size" -> current.copy(size = parseSize(value))
|
||||
"url" -> current.copy(url = value ?: current.url)
|
||||
else -> current
|
||||
}
|
||||
}
|
||||
i = m.range.last + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 累积普通文本,直到下一个 '['
|
||||
val next = raw.indexOf('[', i)
|
||||
val text = if (next < 0) raw.substring(i) else raw.substring(i, next)
|
||||
if (text.isNotEmpty()) {
|
||||
appendStyled(builder, current, text)
|
||||
i += text.length
|
||||
} else {
|
||||
// 当前字符是 '[' 但不是合法 BBCode 标签(如 [歌名] 等中文占位符):
|
||||
// 按普通字符保留,避免被吞掉
|
||||
appendStyled(builder, current, raw[i].toString())
|
||||
i++
|
||||
}
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
private fun appendStyled(builder: AnnotatedString.Builder, style: RunStyle, text: String) {
|
||||
val link = style.url
|
||||
if (link != null) {
|
||||
// 链接:可点击(Text 通过 LocalUriHandler 打开)+ 下划线 + 链接色
|
||||
builder.withLink(
|
||||
LinkAnnotation.Url(
|
||||
link,
|
||||
TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)),
|
||||
),
|
||||
) {
|
||||
// 链接内部仍保留 b/i/颜色/字号等基础格式
|
||||
pushStyle(styleSpan(style.copy(url = null, color = style.color)))
|
||||
append(text)
|
||||
pop()
|
||||
}
|
||||
} else {
|
||||
builder.pushStyle(styleSpan(style))
|
||||
builder.append(text)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun styleSpan(s: RunStyle): SpanStyle {
|
||||
val decoration = when {
|
||||
s.underline -> TextDecoration.Underline
|
||||
s.strike -> TextDecoration.LineThrough
|
||||
else -> null
|
||||
}
|
||||
return SpanStyle(
|
||||
fontWeight = if (s.bold) FontWeight.Bold else null,
|
||||
fontStyle = if (s.italic) FontStyle.Italic else null,
|
||||
textDecoration = decoration,
|
||||
color = s.color ?: Color.Unspecified,
|
||||
fontSize = s.size ?: TextUnit.Unspecified,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseColor(value: String?): Color? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
val v = value.removePrefix("#").removePrefix("0x")
|
||||
if (v.length == 6) {
|
||||
return try { Color(("FF" + v).toLong(16)) } catch (_: Exception) { null }
|
||||
}
|
||||
if (v.length == 8) {
|
||||
return try { Color(v.toLong(16)) } catch (_: Exception) { null }
|
||||
}
|
||||
return when (value.lowercase()) {
|
||||
"red" -> Color(0xFFFF0000); "green" -> Color(0xFF00FF00)
|
||||
"blue" -> Color(0xFF0000FF); "yellow" -> Color(0xFFFFFF00)
|
||||
"white" -> Color(0xFFFFFFFF); "black" -> Color(0xFF000000)
|
||||
"orange" -> Color(0xFFFFA500); "purple" -> Color(0xFF800080)
|
||||
"grey", "gray" -> Color(0xFF808080)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSize(value: String?): TextUnit? {
|
||||
val n = value?.toFloatOrNull() ?: return null
|
||||
return n.coerceIn(6f, 40f).sp
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ private fun ChannelInfoSection(channelDetail: ChannelDetailInfo) {
|
||||
if (channelDetail.description.isNotEmpty()) {
|
||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||
Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(channelDetail.description, style = MaterialTheme.typography.bodySmall)
|
||||
Text(Bbcode.parse(channelDetail.description), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,22 +27,30 @@ fun MessageList(
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致
|
||||
val messageItems = messages.distinctBy { it.id }
|
||||
|
||||
// 是否在底部(用于判断是否自动滚动)
|
||||
val isAtBottom by remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
|
||||
lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2
|
||||
}
|
||||
}
|
||||
|
||||
// 新消息到达时自动滚动到底部
|
||||
LaunchedEffect(messages.size) {
|
||||
val count = messages.size
|
||||
LaunchedEffect(messageItems.size) {
|
||||
val count = messageItems.size
|
||||
if (count > 0) {
|
||||
// 先等当前帧(measure/layout)完成再滚动,避免在 LazyColumn measure 期间操作
|
||||
// scrollState 触发 "layout state is not idle before measure starts" 崩溃
|
||||
withFrameNanos { }
|
||||
try {
|
||||
listState.animateScrollToItem(count - 1)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可
|
||||
} catch (_: IllegalStateException) {
|
||||
// scrollState 尚未 idle(measure 竞态/键盘布局变化),忽略本次滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +63,7 @@ fun MessageList(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = messages,
|
||||
items = messageItems,
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
MessageItem(message = message)
|
||||
@@ -63,7 +71,7 @@ fun MessageList(
|
||||
}
|
||||
|
||||
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
|
||||
if (!isAtBottom && messages.isNotEmpty()) {
|
||||
if (!isAtBottom && messageItems.isNotEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -76,13 +84,17 @@ fun MessageList(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val count = messages.size
|
||||
val count = messageItems.size
|
||||
if (count > 0) {
|
||||
coroutineScope.launch {
|
||||
// 等一帧确保布局完成后再滚动
|
||||
withFrameNanos { }
|
||||
try {
|
||||
listState.animateScrollToItem(count - 1)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// 列表在点击瞬间被清空/缩短,忽略即可
|
||||
} catch (_: IllegalStateException) {
|
||||
// scrollState 尚未 idle,忽略本次滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ fun ServerDetailCard(
|
||||
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
|
||||
SectionCard(title = "欢迎消息") {
|
||||
Text(
|
||||
text = serverInfo.welcomeMessage,
|
||||
text = Bbcode.parse(serverInfo.welcomeMessage),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -178,6 +179,7 @@ private fun MemberRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 64.dp)
|
||||
.clip(RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
|
||||
|
||||
@@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val TAG = "ChatViewModel"
|
||||
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
||||
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
||||
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */
|
||||
private val sysMsgSeq = AtomicLong(0)
|
||||
/** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一,
|
||||
* 避免 LazyColumn/Row 以 id 为 key 时出现 "Key was already used" 崩溃 */
|
||||
private val msgIdSeq = AtomicLong(0)
|
||||
}
|
||||
|
||||
// 当前会话的消息列表
|
||||
@@ -94,7 +95,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
fun addSystemMessage(content: String) {
|
||||
val message = ChatMessage(
|
||||
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}",
|
||||
id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}",
|
||||
targetMode = 2,
|
||||
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
||||
senderId = 0,
|
||||
@@ -132,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
||||
|
||||
// 生成唯一 ID(用于匹配回显)
|
||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
||||
// 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复
|
||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}"
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
// 1. 立即添加到本地列表(PENDING 状态)
|
||||
@@ -280,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
|
||||
val message = ChatMessage(
|
||||
id = "${System.currentTimeMillis()}_${senderId}",
|
||||
// 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复
|
||||
id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}",
|
||||
targetMode = targetMode,
|
||||
targetId = targetId,
|
||||
senderId = senderId,
|
||||
|
||||
Reference in New Issue
Block a user