Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d06ff2ade | ||
|
|
f27ba9509d |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.tsmobile.app"
|
applicationId = "com.tsmobile.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 9
|
versionCode = 11
|
||||||
versionName = "1.0.9"
|
versionName = "1.0.11"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
android:name=".ConnectionService"
|
android:name=".ConnectionService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- FileProvider:导出/分享崩溃日志文件 -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -56,6 +56,52 @@ class ConnectionService : LifecycleService() {
|
|||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
android.util.Log.i(TAG, "Stopping connection service")
|
android.util.Log.i(TAG, "Stopping connection service")
|
||||||
context.stopService(Intent(context, ConnectionService::class.java))
|
context.stopService(Intent(context, ConnectionService::class.java))
|
||||||
|
// 兜底:立即取消保活通知,避免服务已停但通知残留(部分 ROM 不会随 stopService 移除前台通知)
|
||||||
|
cancelNotification(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除保活通知(前台通知的兜底清理) */
|
||||||
|
private fun cancelNotification(context: Context) {
|
||||||
|
try {
|
||||||
|
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
|
||||||
|
.cancel(NOTIFICATION_ID)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(TAG, "Failed to cancel keepalive notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新保活通知(用于“重连中”等状态变化)。
|
||||||
|
* 服务仍在运行时,会更新前台通知;否则投递一条普通通知。
|
||||||
|
*/
|
||||||
|
fun update(context: Context, title: String, contentText: String) {
|
||||||
|
android.util.Log.i(TAG, "Updating keepalive notification: $title")
|
||||||
|
try {
|
||||||
|
(context.getSystemService(NotificationManager::class.java) as NotificationManager)
|
||||||
|
.notify(NOTIFICATION_ID, buildNotification(context, title, contentText))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w(TAG, "Failed to update keepalive notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(context: Context, title: String, contentText: String): Notification {
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,7 +152,7 @@ class ConnectionService : LifecycleService() {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(serverName), serviceType)
|
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("已连接到 TeamSpeak", serverName), serviceType)
|
||||||
|
|
||||||
android.util.Log.i(TAG, "Foreground service started for $serverName")
|
android.util.Log.i(TAG, "Foreground service started for $serverName")
|
||||||
return Service.START_NOT_STICKY
|
return Service.START_NOT_STICKY
|
||||||
@@ -114,6 +160,9 @@ class ConnectionService : LifecycleService() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
releaseLocks()
|
releaseLocks()
|
||||||
|
// 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
cancelNotification(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
android.util.Log.i(TAG, "Connection service destroyed, locks released")
|
android.util.Log.i(TAG, "Connection service destroyed, locks released")
|
||||||
}
|
}
|
||||||
@@ -180,24 +229,6 @@ class ConnectionService : LifecycleService() {
|
|||||||
nm.createNotificationChannel(channel)
|
nm.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(serverName: String): Notification {
|
private fun buildNotification(title: String, contentText: String): Notification =
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
buildNotification(this, title, contentText)
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
||||||
.setContentTitle("已连接到 TeamSpeak")
|
|
||||||
.setContentText(serverName)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setShowWhen(false)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
if (channelDetail.description.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
Spacer(Modifier.height(UiTokens.Spacing.Small))
|
||||||
Text("描述", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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 listState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 统一数据源:去重(兜底防重复 key 崩溃),后续都用 messageItems,避免 count 与 items 不一致
|
||||||
|
val messageItems = messages.distinctBy { it.id }
|
||||||
|
|
||||||
// 是否在底部(用于判断是否自动滚动)
|
// 是否在底部(用于判断是否自动滚动)
|
||||||
val isAtBottom by remember {
|
val isAtBottom by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
|
lastVisibleItem != null && lastVisibleItem.index >= messageItems.size - 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新消息到达时自动滚动到底部
|
// 新消息到达时自动滚动到底部
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messageItems.size) {
|
||||||
val count = messages.size
|
val count = messageItems.size
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
|
// 先等当前帧(measure/layout)完成再滚动,避免在 LazyColumn measure 期间操作
|
||||||
|
// scrollState 触发 "layout state is not idle before measure starts" 崩溃
|
||||||
|
withFrameNanos { }
|
||||||
try {
|
try {
|
||||||
listState.animateScrollToItem(count - 1)
|
listState.animateScrollToItem(count - 1)
|
||||||
} catch (_: IllegalArgumentException) {
|
} catch (_: IllegalArgumentException) {
|
||||||
// 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可
|
// 列表在滚动过程中被清空/缩短(如断线清理会话),忽略即可
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// scrollState 尚未 idle(measure 竞态/键盘布局变化),忽略本次滚动
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +63,7 @@ fun MessageList(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = messages,
|
items = messageItems,
|
||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { message ->
|
) { message ->
|
||||||
MessageItem(message = message)
|
MessageItem(message = message)
|
||||||
@@ -63,7 +71,7 @@ fun MessageList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
|
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
|
||||||
if (!isAtBottom && messages.isNotEmpty()) {
|
if (!isAtBottom && messageItems.isNotEmpty()) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
@@ -76,13 +84,17 @@ fun MessageList(
|
|||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val count = messages.size
|
val count = messageItems.size
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
// 等一帧确保布局完成后再滚动
|
||||||
|
withFrameNanos { }
|
||||||
try {
|
try {
|
||||||
listState.animateScrollToItem(count - 1)
|
listState.animateScrollToItem(count - 1)
|
||||||
} catch (_: IllegalArgumentException) {
|
} catch (_: IllegalArgumentException) {
|
||||||
// 列表在点击瞬间被清空/缩短,忽略即可
|
// 列表在点击瞬间被清空/缩短,忽略即可
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// scrollState 尚未 idle,忽略本次滚动
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.tsmobile.app.ui.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -103,7 +104,7 @@ fun ServerDetailCard(
|
|||||||
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
|
if (serverInfo != null && serverInfo.welcomeMessage.isNotEmpty()) {
|
||||||
SectionCard(title = "欢迎消息") {
|
SectionCard(title = "欢迎消息") {
|
||||||
Text(
|
Text(
|
||||||
text = serverInfo.welcomeMessage,
|
text = Bbcode.parse(serverInfo.welcomeMessage),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,20 +234,26 @@ private fun readCrashLog(context: Context): String {
|
|||||||
return texts.distinct().joinToString("\n\n")
|
return texts.distinct().joinToString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通过系统分享面板导出崩溃日志(无日志时提示)。 */
|
/** 通过系统分享面板导出崩溃日志:写入 .log 文件并作为附件分享(非纯文本)。 */
|
||||||
private fun shareCrashLog(context: Context) {
|
private fun shareCrashLog(context: Context) {
|
||||||
val log = readCrashLog(context)
|
val log = readCrashLog(context)
|
||||||
if (log.isBlank()) {
|
if (log.isBlank()) {
|
||||||
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val file = writeCrashLogToFile(context, log)
|
||||||
|
if (file == null) {
|
||||||
|
Toast.makeText(context, "生成日志文件失败", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
val send = Intent(Intent.ACTION_SEND).apply {
|
val send = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志")
|
putExtra(Intent.EXTRA_SUBJECT, "TeamSpeak Mobile 崩溃日志")
|
||||||
putExtra(Intent.EXTRA_TEXT, log)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
val chooser = Intent.createChooser(send, "导出崩溃日志")
|
val chooser = Intent.createChooser(send, "导出崩溃日志")
|
||||||
// 若 context 不是 Activity,需 NEW_TASK
|
|
||||||
if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try {
|
try {
|
||||||
context.startActivity(chooser)
|
context.startActivity(chooser)
|
||||||
@@ -254,3 +261,15 @@ private fun shareCrashLog(context: Context) {
|
|||||||
Toast.makeText(context, "无法打开分享:${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "无法打开分享:${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将日志写入 cacheDir/logs/tsmobile_crash_<时间戳>.log 文件(供 FileProvider 分享)。 */
|
||||||
|
private fun writeCrashLogToFile(context: Context, log: String): File? {
|
||||||
|
return try {
|
||||||
|
val dir = File(context.cacheDir, "logs").apply { mkdirs() }
|
||||||
|
val file = File(dir, "tsmobile_crash_${System.currentTimeMillis()}.log")
|
||||||
|
file.writeText(log)
|
||||||
|
file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -178,6 +179,7 @@ private fun MemberRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 64.dp)
|
.heightIn(min = 64.dp)
|
||||||
|
.clip(RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||||
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
|
.background(containerColor, RoundedCornerShape(UiTokens.Spacing.Medium))
|
||||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||||
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
|
.padding(horizontal = 14.dp, vertical = UiTokens.Spacing.Medium),
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
/** 消息送达确认超时(毫秒):超时未收到回显则标记为 FAILED */
|
||||||
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
private const val DELIVERY_TIMEOUT_MS = 10_000L
|
||||||
/** 系统消息 ID 计数器,避免同毫秒并发调用产生重复 key */
|
/** 全局消息 ID 递增序列:保证同一毫秒内生成的消息 ID 唯一,
|
||||||
private val sysMsgSeq = AtomicLong(0)
|
* 避免 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) {
|
fun addSystemMessage(content: String) {
|
||||||
val message = ChatMessage(
|
val message = ChatMessage(
|
||||||
id = "sys_${System.currentTimeMillis()}_${sysMsgSeq.incrementAndGet()}",
|
id = "sys_${System.currentTimeMillis()}_${msgIdSeq.incrementAndGet()}",
|
||||||
targetMode = 2,
|
targetMode = 2,
|
||||||
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
targetId = Repository.currentChannelId.value.toLongOrNull() ?: 0L,
|
||||||
senderId = 0,
|
senderId = 0,
|
||||||
@@ -132,7 +133,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
val selfName = Repository.getClient(selfId)?.nickname ?: ""
|
||||||
|
|
||||||
// 生成唯一 ID(用于匹配回显)
|
// 生成唯一 ID(用于匹配回显)
|
||||||
val messageId = "local_${System.currentTimeMillis()}_${selfId}"
|
// 追加全局递增 seq,避免同毫秒多条本地消息 ID 重复
|
||||||
|
val messageId = "local_${System.currentTimeMillis()}_${selfId}_${msgIdSeq.incrementAndGet()}"
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
|
|
||||||
// 1. 立即添加到本地列表(PENDING 状态)
|
// 1. 立即添加到本地列表(PENDING 状态)
|
||||||
@@ -280,7 +282,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
|
// 非自己的消息,或无法匹配的自己的消息 → 正常归档
|
||||||
val message = ChatMessage(
|
val message = ChatMessage(
|
||||||
id = "${System.currentTimeMillis()}_${senderId}",
|
// 追加全局递增 seq,避免同毫秒同发送者多条消息 ID 重复
|
||||||
|
id = "${System.currentTimeMillis()}_${senderId}_${msgIdSeq.incrementAndGet()}",
|
||||||
targetMode = targetMode,
|
targetMode = targetMode,
|
||||||
targetId = targetId,
|
targetId = targetId,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
lastChannelId = chId
|
lastChannelId = chId
|
||||||
}
|
}
|
||||||
_connectionState.value = ConnectionState.WaitingForNetwork()
|
_connectionState.value = ConnectionState.WaitingForNetwork()
|
||||||
ConnectionService.stop(getApplication())
|
ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
|
||||||
if (networkMonitor.isAvailable.value) {
|
if (networkMonitor.isAvailable.value) {
|
||||||
startReconnect("网络变化,正在重连...")
|
startReconnect("网络变化,正在重连...")
|
||||||
}
|
}
|
||||||
@@ -898,6 +898,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
// 重连失败
|
// 重连失败
|
||||||
android.util.Log.w(TAG, "All reconnect attempts failed")
|
android.util.Log.w(TAG, "All reconnect attempts failed")
|
||||||
|
ConnectionService.stop(getApplication()) // 清除“重连中”通知
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_connectionState.value = ConnectionState.Disconnected(
|
_connectionState.value = ConnectionState.Disconnected(
|
||||||
@@ -916,6 +917,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
val params = lastConnectParams ?: return
|
val params = lastConnectParams ?: return
|
||||||
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
|
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
|
||||||
|
ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val error = withContext(Dispatchers.IO) {
|
val error = withContext(Dispatchers.IO) {
|
||||||
@@ -951,6 +953,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
lastChannelId = ""
|
lastChannelId = ""
|
||||||
networkMonitor.reset()
|
networkMonitor.reset()
|
||||||
|
ConnectionService.stop(getApplication()) // 清除“重连中”通知
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
_connectionState.value = ConnectionState.Disconnected(
|
_connectionState.value = ConnectionState.Disconnected(
|
||||||
@@ -1077,17 +1080,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
|
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
|
||||||
_connectionState.value is ConnectionState.Reconnecting
|
_connectionState.value is ConnectionState.Reconnecting
|
||||||
) {
|
) {
|
||||||
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, stopping notification")
|
android.util.Log.d(TAG, "onDisconnected while already handling reconnect, keeping reconnecting notification")
|
||||||
ConnectionService.stop(getApplication())
|
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音断开处理
|
// 语音断开处理
|
||||||
voiceViewModel?.onDisconnected()
|
voiceViewModel?.onDisconnected()
|
||||||
|
|
||||||
// 停止前台服务
|
|
||||||
ConnectionService.stop(getApplication())
|
|
||||||
|
|
||||||
// 判断断线原因:网络丢失 vs 服务器主动断线
|
// 判断断线原因:网络丢失 vs 服务器主动断线
|
||||||
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
|
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
|
||||||
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
|
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
|
||||||
@@ -1098,6 +1098,8 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
message.contains("timeout", ignoreCase = true)
|
message.contains("timeout", ignoreCase = true)
|
||||||
|
|
||||||
if (isNetworkLoss) {
|
if (isNetworkLoss) {
|
||||||
|
// 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
|
||||||
|
ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
|
||||||
// 记录当前频道,供重连后自动回到原频道
|
// 记录当前频道,供重连后自动回到原频道
|
||||||
val currentChId = Repository.currentChannelId.value
|
val currentChId = Repository.currentChannelId.value
|
||||||
if (currentChId != "0" && currentChId.isNotEmpty()) {
|
if (currentChId != "0" && currentChId.isNotEmpty()) {
|
||||||
@@ -1110,8 +1112,9 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
|
android.util.Log.i(TAG, "Network-loss disconnect detected (fallback path)")
|
||||||
startReconnect("网络变化,正在重连...")
|
startReconnect("网络变化,正在重连...")
|
||||||
} else {
|
} else {
|
||||||
// 服务器主动断线 — 清理会话,回到首页
|
// 服务器主动断线 — 清除通知、清理会话,回到首页
|
||||||
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
|
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
|
||||||
|
ConnectionService.stop(getApplication())
|
||||||
lastChannelId = ""
|
lastChannelId = ""
|
||||||
Repository.clearSession()
|
Repository.clearSession()
|
||||||
channelViewModel?.clearChannels()
|
channelViewModel?.clearChannels()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FileProvider 路径映射:暴露 cacheDir/logs 下的日志文件用于分享 -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="logs" path="logs/" />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user