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 @@ + + + + +