diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 7aaf7ca..1d5a21c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.tsmobile.app"
minSdk = 26
targetSdk = 35
- versionCode = 10
- versionName = "1.0.10"
+ versionCode = 11
+ versionName = "1.0.11"
}
signingConfigs {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b259abb..0d22bbe 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -48,6 +48,17 @@
android:name=".ConnectionService"
android:foregroundServiceType="dataSync"
android:exported="false" />
+
+
+
+
+
diff --git a/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt b/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
index e4bb26c..15d8196 100644
--- a/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
+++ b/android/app/src/main/java/com/tsmobile/app/ConnectionService.kt
@@ -56,6 +56,52 @@ class ConnectionService : LifecycleService() {
fun stop(context: Context) {
android.util.Log.i(TAG, "Stopping connection service")
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 {
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")
return Service.START_NOT_STICKY
@@ -114,6 +160,9 @@ class ConnectionService : LifecycleService() {
override fun onDestroy() {
releaseLocks()
+ // 显式移除前台通知,确保断开/停止服务后通知被清除,而不是残留“已连接”
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ cancelNotification(this)
super.onDestroy()
android.util.Log.i(TAG, "Connection service destroyed, locks released")
}
@@ -180,24 +229,6 @@ class ConnectionService : LifecycleService() {
nm.createNotificationChannel(channel)
}
- private fun buildNotification(serverName: String): Notification {
- val pendingIntent = PendingIntent.getActivity(
- 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()
- }
+ private fun buildNotification(title: String, contentText: String): Notification =
+ buildNotification(this, title, contentText)
}
diff --git a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt
index 5fdc8a9..390b39b 100644
--- a/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt
+++ b/android/app/src/main/java/com/tsmobile/app/ui/components/ServerDetailCard.kt
@@ -3,6 +3,7 @@ package com.tsmobile.app.ui.components
import android.content.Context
import android.content.Intent
import android.widget.Toast
+import androidx.core.content.FileProvider
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -233,20 +234,26 @@ private fun readCrashLog(context: Context): String {
return texts.distinct().joinToString("\n\n")
}
-/** 通过系统分享面板导出崩溃日志(无日志时提示)。 */
+/** 通过系统分享面板导出崩溃日志:写入 .log 文件并作为附件分享(非纯文本)。 */
private fun shareCrashLog(context: Context) {
val log = readCrashLog(context)
if (log.isBlank()) {
Toast.makeText(context, "暂无崩溃日志", Toast.LENGTH_SHORT).show()
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 {
type = "text/plain"
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, "导出崩溃日志")
- // 若 context 不是 Activity,需 NEW_TASK
if (context !is android.app.Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(chooser)
@@ -254,3 +261,15 @@ private fun shareCrashLog(context: Context) {
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
+ }
+}
diff --git a/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt b/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt
index 7d1f60a..cf92948 100644
--- a/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt
+++ b/android/app/src/main/java/com/tsmobile/app/viewmodel/ServerViewModel.kt
@@ -192,7 +192,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = chId
}
_connectionState.value = ConnectionState.WaitingForNetwork()
- ConnectionService.stop(getApplication())
+ ConnectionService.update(getApplication(), "网络已断开,正在重连...", _state.value.address.trim())
if (networkMonitor.isAvailable.value) {
startReconnect("网络变化,正在重连...")
}
@@ -898,6 +898,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
// 重连失败
android.util.Log.w(TAG, "All reconnect attempts failed")
+ ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -916,6 +917,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
val params = lastConnectParams ?: return
_connectionState.value = ConnectionState.Reconnecting(attempt = 0, reason = "手动重连")
+ ConnectionService.update(getApplication(), "正在重连...", _state.value.address.trim())
viewModelScope.launch {
val error = withContext(Dispatchers.IO) {
@@ -951,6 +953,7 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
lastChannelId = ""
networkMonitor.reset()
+ ConnectionService.stop(getApplication()) // 清除“重连中”通知
Repository.clearSession()
channelViewModel?.clearChannels()
_connectionState.value = ConnectionState.Disconnected(
@@ -1077,17 +1080,14 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
if (_connectionState.value is ConnectionState.WaitingForNetwork ||
_connectionState.value is ConnectionState.Reconnecting
) {
- android.util.Log.d(TAG, "onDisconnected while already handling reconnect, stopping notification")
- ConnectionService.stop(getApplication())
+ android.util.Log.d(TAG, "onDisconnected while already handling reconnect, keeping reconnecting notification")
+ ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
return
}
// 语音断开处理
voiceViewModel?.onDisconnected()
- // 停止前台服务
- ConnectionService.stop(getApplication())
-
// 判断断线原因:网络丢失 vs 服务器主动断线
// - ConnectivityManager 明确检测到网络切换 → 网络丢失
// - 断线原因为空或 timeout → TCP 静默断开(NAT 超时、运营商丢弃等),
@@ -1098,6 +1098,8 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
message.contains("timeout", ignoreCase = true)
if (isNetworkLoss) {
+ // 网络丢失 → 重连,通知显示“重连中”(保留前台服务与保活)
+ ConnectionService.update(getApplication(), "连接已断开,正在重连...", _state.value.address.trim())
// 记录当前频道,供重连后自动回到原频道
val currentChId = Repository.currentChannelId.value
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)")
startReconnect("网络变化,正在重连...")
} else {
- // 服务器主动断线 — 清理会话,回到首页
+ // 服务器主动断线 — 清除通知、清理会话,回到首页
android.util.Log.w(TAG, "Server-initiated disconnect: $message")
+ ConnectionService.stop(getApplication())
lastChannelId = ""
Repository.clearSession()
channelViewModel?.clearChannels()
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..0ac2acc
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+