feat: term session mgr (#846)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-12 23:43:42 +08:00
committed by GitHub
parent 584af5423a
commit 9b01da5a23
39 changed files with 1275 additions and 55 deletions

View File

@@ -5,11 +5,28 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.*
class ForegroundService : Service() {
companion object {
@Volatile
var isRunning: Boolean = false
}
private val chanId = "ForegroundServiceChannel"
private val GROUP_KEY = "ssh_sessions_group"
private val SUMMARY_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private var isFgStarted = false
private val postedIds = mutableSetOf<Int>()
// Stable mapping from session-id -> notification-id to avoid hash collisions
private val notificationIdMap = mutableMapOf<String, Int>()
private val nextNotificationId = java.util.concurrent.atomic.AtomicInteger(2001)
private fun logError(message: String, error: Throwable? = null) {
Log.e("ForegroundService", message, error)
@@ -26,6 +43,7 @@ class ForegroundService : Service() {
override fun onCreate() {
super.onCreate()
Log.d("ForegroundService", "Service onCreate")
isRunning = true
createNotificationChannel()
}
@@ -50,24 +68,20 @@ class ForegroundService : Service() {
val action = intent.action
Log.d("ForegroundService", "onStartCommand action=$action")
// Create notification before starting foreground
val notification = createNotification()
// Use try-catch for startForeground
try {
startForeground(1, notification)
} catch (e: Exception) {
logError("Failed to start foreground", e)
stopSelf()
return START_NOT_STICKY
}
return when (action) {
"ACTION_STOP_FOREGROUND" -> {
ACTION_STOP_FOREGROUND -> {
clearAll()
stopForegroundService()
START_NOT_STICKY
}
ACTION_UPDATE_SESSIONS -> {
val payload = intent.getStringExtra("payload") ?: "{}"
handleUpdateSessions(payload)
START_STICKY
}
else -> {
// Default bring up foreground with placeholder
ensureForeground(createSummaryNotification(0, emptyList()))
START_STICKY
}
}
@@ -101,24 +115,99 @@ class ForegroundService : Service() {
}
}
private fun createNotification(): Notification {
private fun ensureForeground(notification: Notification) {
try {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
val deleteIntent = Intent(this, ForegroundService::class.java).apply {
action = "ACTION_STOP_FOREGROUND"
if (!isFgStarted) {
startForeground(SUMMARY_ID, notification)
isFgStarted = true
} else {
val nm = getSystemService(NotificationManager::class.java)
nm?.notify(SUMMARY_ID, notification)
}
val deletePendingIntent = PendingIntent.getService(
this,
0,
deleteIntent,
PendingIntent.FLAG_IMMUTABLE
} catch (e: Exception) {
logError("Failed to start/update foreground", e)
}
}
private fun createSummaryNotification(count: Int, lines: List<String>): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP_FOREGROUND }
val stopPending = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, chanId)
} else {
Notification.Builder(this)
}
val inbox = Notification.InboxStyle()
lines.forEach { inbox.addLine(it) }
return builder
.setContentTitle("SSH sessions: $count active")
.setContentText(if (lines.isNotEmpty()) lines.first() else "Running")
.setSmallIcon(R.mipmap.ic_launcher)
.setStyle(inbox)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", stopPending)
.build()
}
private fun handleUpdateSessions(payload: String) {
val nm = getSystemService(NotificationManager::class.java)
if (nm == null) {
logError("NotificationManager null")
return
}
val sessions = mutableListOf<SessionItem>()
try {
val obj = JSONObject(payload)
val arr: JSONArray = obj.optJSONArray("sessions") ?: JSONArray()
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
val id = s.optString("id")
val title = s.optString("title")
val sub = s.optString("subtitle")
val whenMs = s.optLong("startTimeMs", System.currentTimeMillis())
val status = s.optString("status", "connected")
if (id.isNotEmpty()) {
sessions.add(SessionItem(id, title, sub, whenMs, status))
}
}
} catch (e: Exception) {
logError("Failed to parse payload", e)
}
// Clear if empty
if (sessions.isEmpty()) {
clearAll()
return
}
// Build per-session notifications
val currentIds = mutableSetOf<Int>()
val summaryLines = mutableListOf<String>()
sessions.forEach { s ->
// Assign a stable, collision-resistant id per session for this service lifecycle
val nid = notificationIdMap.getOrPut(s.id) { nextNotificationId.getAndIncrement() }
currentIds.add(nid)
summaryLines.add("${s.title}: ${s.status}")
val disconnectIntent = Intent(this, MainActivity::class.java).apply {
action = ACTION_DISCONNECT_SESSION
putExtra("session_id", s.id)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val disconnectPending = PendingIntent.getActivity(
this, nid, disconnectIntent, PendingIntent.FLAG_IMMUTABLE
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -127,23 +216,60 @@ class ForegroundService : Service() {
Notification.Builder(this)
}
return builder
.setContentTitle("Server Box")
.setContentText("Running in background")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", deletePendingIntent)
.build()
} catch (e: Exception) {
logError("Error creating notification", e)
// Return a basic notification as fallback
return Notification.Builder(this)
.setContentTitle("Server Box")
val noti = builder
.setContentTitle(s.title)
.setContentText("${s.subtitle} · ${s.status}")
.setSmallIcon(R.mipmap.ic_launcher)
.setWhen(s.startWhen)
.setUsesChronometer(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.addAction(android.R.drawable.ic_media_pause, "Disconnect", disconnectPending)
.build()
nm.notify(nid, noti)
}
// Cancel stale ones
val toCancel = postedIds - currentIds
toCancel.forEach { nm.cancel(it) }
// Clean up id mappings for canceled notifications to prevent growth
if (toCancel.isNotEmpty()) {
val keysToRemove = notificationIdMap.filterValues { it in toCancel }.keys
keysToRemove.forEach { notificationIdMap.remove(it) }
}
postedIds.clear()
postedIds.addAll(currentIds)
// Post/update summary and ensure foreground
val maxSummaryLines = 5
val truncated = summaryLines.size > maxSummaryLines
val displaySummaryLines = if (truncated) {
summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more"
} else {
summaryLines
}
val summary = createSummaryNotification(sessions.size, displaySummaryLines)
ensureForeground(summary)
}
private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(SUMMARY_ID)
postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear()
isFgStarted = false
}
data class SessionItem(
val id: String,
val title: String,
val subtitle: String,
val startWhen: Long,
val status: String,
)
private fun stopForegroundService() {
try {
stopForeground(true)
@@ -157,5 +283,6 @@ class ForegroundService : Service() {
override fun onDestroy() {
super.onDestroy()
Log.d("ForegroundService", "Service onDestroy")
isRunning = false
}
}
}

View File

@@ -13,20 +13,32 @@ import android.appwidget.AppWidgetManager
import tech.lolli.toolbox.widget.HomeWidget
class MainActivity: FlutterFragmentActivity() {
private lateinit var channel: MethodChannel
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val binaryMessenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan").apply {
setMethodCallHandler { method, result ->
channel = MethodChannel(binaryMessenger, "tech.lolli.toolbox/main_chan")
channel.setMethodCallHandler { method, result ->
when (method.method) {
"sendToBackground" -> {
moveTaskToBack(true)
result.success(null)
}
"isServiceRunning" -> {
result.success(ForegroundService.isRunning)
}
"startService" -> {
try {
reqPerm()
if (!notificationsAllowed()) {
// Don't start foreground service without notification permission on API 33+
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
return@setMethodCallHandler
}
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
@@ -51,12 +63,35 @@ class MainActivity: FlutterFragmentActivity() {
sendBroadcast(intent)
result.success(null)
}
"updateSessions" -> {
try {
if (!notificationsAllowed()) {
// Avoid starting/continuing service updates when notifications are blocked
result.error("NOTIFICATION_PERMISSION_DENIED", "Notification permission not granted", null)
return@setMethodCallHandler
}
val serviceIntent = Intent(this@MainActivity, ForegroundService::class.java)
serviceIntent.action = ACTION_UPDATE_SESSIONS
serviceIntent.putExtra("payload", method.arguments as String)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
result.success(null)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to update sessions: ${e.message}")
result.error("SERVICE_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}
// Handle intent if launched via notification action
handleActionIntent(intent)
}
private fun reqPerm() {
@@ -77,5 +112,33 @@ class MainActivity: FlutterFragmentActivity() {
}
}
}
}
private fun notificationsAllowed(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
true
} else {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleActionIntent(intent)
}
private fun handleActionIntent(intent: Intent?) {
if (intent == null) return
when (intent.action) {
ACTION_DISCONNECT_SESSION -> {
val sessionId = intent.getStringExtra("session_id")
if (sessionId != null && ::channel.isInitialized) {
try {
channel.invokeMethod("disconnectSession", mapOf("id" to sessionId))
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke disconnect: ${e.message}")
}
}
}
}
}
}