Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc3047a6f | ||
|
|
f968708284 |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.tsmobile.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 5
|
||||
versionName = "1.0.5"
|
||||
versionCode = 6
|
||||
versionName = "1.0.6"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 保活:防止 CPU 休眠导致 UDP socket 无法收发包 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- 电池优化豁免:防止息屏后 Doze 模式限制网络 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -8,18 +8,24 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
|
||||
/**
|
||||
* 前台服务:保持与 TeamSpeak 服务器的连接。
|
||||
* 前台服务:保活 TeamSpeak 连接。
|
||||
*
|
||||
* 三层保活策略:
|
||||
* 1. 前台服务通知 → 防止 Android 杀死进程
|
||||
* 2. WakeLock (PARTIAL) → 防止 CPU 休眠导致 UDP socket 无法收发包
|
||||
* 3. WiFi Lock → 防止 WiFi 进入低功耗模式断连
|
||||
*
|
||||
* 连接成功时启动,断开/被踢时停止。
|
||||
* 通过 startForeground() 显示持续通知,防止 Android 杀死进程。
|
||||
*
|
||||
* 使用 FOREGROUND_SERVICE_TYPE_DATA_SYNC 类型,适合网络数据同步场景。
|
||||
*/
|
||||
class ConnectionService : LifecycleService() {
|
||||
|
||||
@@ -29,13 +35,15 @@ class ConnectionService : LifecycleService() {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
const val EXTRA_SERVER_NAME = "server_name"
|
||||
|
||||
/** WakeLock tag */
|
||||
private const val WAKELOCK_TAG = "tsmobile:connection"
|
||||
|
||||
/**
|
||||
* 启动前台服务。
|
||||
* @param context 上下文
|
||||
* @param serverName 服务器地址(显示在通知中)
|
||||
*/
|
||||
fun start(context: Context, serverName: String) {
|
||||
android.util.Log.i(TAG, "Starting connection service for $serverName")
|
||||
requestBatteryExemptionIfNeeded(context)
|
||||
val intent = Intent(context, ConnectionService::class.java).apply {
|
||||
putExtra(EXTRA_SERVER_NAME, serverName)
|
||||
}
|
||||
@@ -49,42 +57,114 @@ class ConnectionService : LifecycleService() {
|
||||
android.util.Log.i(TAG, "Stopping connection service")
|
||||
context.stopService(Intent(context, ConnectionService::class.java))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查电池优化豁免状态,若未豁免则引导用户设置。
|
||||
* 仅在首次调用时弹窗(系统设置页面),用户设置后不再提示。
|
||||
*/
|
||||
fun requestBatteryExemptionIfNeeded(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
|
||||
val pm = context.getSystemService(POWER_SERVICE) as PowerManager
|
||||
if (pm.isIgnoringBatteryOptimizations(context.packageName)) {
|
||||
android.util.Log.d(TAG, "Already exempt from battery optimization")
|
||||
return
|
||||
}
|
||||
|
||||
android.util.Log.i(TAG, "Requesting battery optimization exemption")
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to open battery optimization settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
acquireWakeLock()
|
||||
acquireWifiLock()
|
||||
ensureChannel()
|
||||
android.util.Log.i(TAG, "Service created, locks acquired")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
val serverName = intent?.getStringExtra(EXTRA_SERVER_NAME) ?: ""
|
||||
val notification = buildNotification(serverName)
|
||||
|
||||
// startForeground 前确保通知频道存在
|
||||
ensureChannel()
|
||||
|
||||
// 启动前台服务
|
||||
// Android 14+ 需要指定 foregroundServiceType
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= 34) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceType)
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(serverName), serviceType)
|
||||
|
||||
android.util.Log.i(TAG, "Foreground service started")
|
||||
// The service exists only to keep the process alive while a TS connection is active.
|
||||
// If Android kills it, there is no reliable way to restore the TS session, so don't
|
||||
// ask the system to recreate it with a misleading "connected" notification.
|
||||
android.util.Log.i(TAG, "Foreground service started for $serverName")
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseLocks()
|
||||
super.onDestroy()
|
||||
android.util.Log.i(TAG, "Connection service destroyed")
|
||||
android.util.Log.i(TAG, "Connection service destroyed, locks released")
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保通知频道已创建。
|
||||
* Android 8.0+ 需要通知频道,重复创建是安全的(系统会忽略已存在的频道)。
|
||||
*/
|
||||
// ── WakeLock ───────────────────────────────────────────────
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWakeLock() {
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG,
|
||||
).apply {
|
||||
acquire()
|
||||
}
|
||||
android.util.Log.i(TAG, "WakeLock acquired (PARTIAL)")
|
||||
}
|
||||
|
||||
private fun releaseLocks() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
android.util.Log.i(TAG, "WakeLock released")
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
|
||||
wifiLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
android.util.Log.i(TAG, "WiFi lock released")
|
||||
}
|
||||
}
|
||||
wifiLock = null
|
||||
}
|
||||
|
||||
// ── WiFi Lock ──────────────────────────────────────────────
|
||||
|
||||
private fun acquireWifiLock() {
|
||||
val wm = applicationContext.getSystemService(WIFI_SERVICE) as? WifiManager ?: return
|
||||
wifiLock = wm.createWifiLock(
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF,
|
||||
"tsmobile:wifi",
|
||||
).apply {
|
||||
acquire()
|
||||
}
|
||||
android.util.Log.i(TAG, "WiFi lock acquired")
|
||||
}
|
||||
|
||||
// ── 通知频道 ───────────────────────────────────────────────
|
||||
|
||||
private fun ensureChannel() {
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
@@ -100,11 +180,7 @@ class ConnectionService : LifecycleService() {
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建通知。
|
||||
*/
|
||||
private fun buildNotification(serverName: String): Notification {
|
||||
// 点击通知回到 MainActivity
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
@@ -119,8 +195,8 @@ class ConnectionService : LifecycleService() {
|
||||
.setContentTitle("已连接到 TeamSpeak")
|
||||
.setContentText(serverName)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true) // 不可滑动清除
|
||||
.setShowWhen(false) // 不显示时间
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -168,17 +168,23 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
// 启动时自动检查更新
|
||||
checkForUpdate()
|
||||
|
||||
// 网络变动检测 — 简单原则:
|
||||
// 任何 NetworkCallback 触发 + 已连接 → 网络一定变了 → 必须重连
|
||||
// (UDP socket 绑定在旧 IP 上,即使 isAvailable 不变也已死)
|
||||
// 网络变动检测:
|
||||
// 任何 NetworkCallback 触发 + 前台 + 已连接 → 重连(UDP socket 绑定在旧 IP 上已死)
|
||||
// 后台忽略:Android 挂起应用时可能限制网络导致误触发,
|
||||
// 真正的断线由 Go idle timeout(60s 无包)兜底,或回到前台时检测
|
||||
//
|
||||
// 两层 collector:
|
||||
// 1. networkChangeCount → 任何网络事件 → Connected 就重连
|
||||
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连
|
||||
// 1. networkChangeCount → 任何网络事件 → Connected 就重连(仅前台)
|
||||
// 2. isAvailable false→true → WaitingForNetwork 时网络恢复 → 重连(不限前后台)
|
||||
viewModelScope.launch {
|
||||
var firstEmission = true
|
||||
networkMonitor.networkChangeCount.collect { _ ->
|
||||
if (firstEmission) { firstEmission = false; return@collect }
|
||||
// 后台忽略:防止 Android 后台网络限制导致 NetworkCallback 误触发
|
||||
if (!isAppInForeground()) {
|
||||
android.util.Log.d(TAG, "Network change ignored (app in background)")
|
||||
return@collect
|
||||
}
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
android.util.Log.w(TAG, "Network changed while connected, reconnecting")
|
||||
val chId = Repository.currentChannelId.value
|
||||
@@ -208,6 +214,10 @@ class ServerViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用是否在前台(RESUMED 状态) */
|
||||
private fun isAppInForeground(): Boolean =
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.RESUMED
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
networkMonitor.stop()
|
||||
|
||||
Reference in New Issue
Block a user