feat: stop all servers in noti center (#901)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-09-06 14:04:53 +08:00
committed by GitHub
parent 0c7b72fb2c
commit 05a927753f
5 changed files with 124 additions and 80 deletions

View File

@@ -16,8 +16,7 @@ class ForegroundService : Service() {
var isRunning: Boolean = false var isRunning: Boolean = false
} }
private val chanId = "ForegroundServiceChannel" private val chanId = "ForegroundServiceChannel"
private val GROUP_KEY = "ssh_sessions_group" private val NOTIFICATION_ID = 1000
private val SUMMARY_ID = 1000
private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND" private val ACTION_STOP_FOREGROUND = "ACTION_STOP_FOREGROUND"
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
@@ -70,6 +69,9 @@ class ForegroundService : Service() {
return when (action) { return when (action) {
ACTION_STOP_FOREGROUND -> { ACTION_STOP_FOREGROUND -> {
// Notify Flutter to stop all connections before stopping service
val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS")
sendBroadcast(stopAllIntent)
clearAll() clearAll()
stopForegroundService() stopForegroundService()
START_NOT_STICKY START_NOT_STICKY
@@ -81,7 +83,7 @@ class ForegroundService : Service() {
} }
else -> { else -> {
// Default bring up foreground with placeholder // Default bring up foreground with placeholder
ensureForeground(createSummaryNotification(0, emptyList())) ensureForeground(createMergedNotification(0, emptyList(), emptyList()))
START_STICKY START_STICKY
} }
} }
@@ -118,18 +120,19 @@ class ForegroundService : Service() {
private fun ensureForeground(notification: Notification) { private fun ensureForeground(notification: Notification) {
try { try {
if (!isFgStarted) { if (!isFgStarted) {
startForeground(SUMMARY_ID, notification) startForeground(NOTIFICATION_ID, notification)
isFgStarted = true isFgStarted = true
} else { } else {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
nm?.notify(SUMMARY_ID, notification) nm?.notify(NOTIFICATION_ID, notification)
} }
} catch (e: Exception) { } catch (e: Exception) {
logError("Failed to start/update foreground", e) logError("Failed to start/update foreground", e)
} }
} }
private fun createSummaryNotification(count: Int, lines: List<String>): Notification {
private fun createMergedNotification(count: Int, lines: List<String>, sessions: List<SessionItem>): Notification {
val notificationIntent = Intent(this, MainActivity::class.java) val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
@@ -143,21 +146,56 @@ class ForegroundService : Service() {
Notification.Builder(this) Notification.Builder(this)
} }
val inbox = Notification.InboxStyle() // Use the earliest session's start time for chronometer
lines.forEach { inbox.addLine(it) } val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis()
return builder val title = when {
.setContentTitle("SSH sessions: $count active") count == 0 -> "Server Box"
.setContentText(if (lines.isNotEmpty()) lines.first() else "Running") count == 1 -> sessions.first().title
else -> "SSH sessions: $count active"
}
val contentText = when {
count == 0 -> "Ready for connections"
count == 1 -> {
val session = sessions.first()
"${session.subtitle} · ${session.status}"
}
else -> "Multiple SSH connections active"
}
// For multiple sessions, show details in expanded view
val style = if (count > 1) {
val inbox = Notification.InboxStyle()
val maxLines = 5
val displayLines = if (lines.size > maxLines) {
lines.take(maxLines) + "...and ${lines.size - maxLines} more"
} else {
lines
}
displayLines.forEach { inbox.addLine(it) }
inbox.setBigContentTitle(title)
inbox
} else {
null
}
val notification = builder
.setContentTitle(title)
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setStyle(inbox) .setWhen(earliestStartTime)
.setUsesChronometer(true)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "Stop", stopPending) .addAction(android.R.drawable.ic_delete, "Stop All", stopPending)
.build()
if (style != null) {
notification.setStyle(style)
}
return notification.build()
} }
private fun handleUpdateSessions(payload: String) { private fun handleUpdateSessions(payload: String) {
@@ -192,71 +230,21 @@ class ForegroundService : Service() {
return return
} }
// Build per-session notifications // Cancel any existing individual notifications (we only show merged notification now)
val currentIds = mutableSetOf<Int>() val toCancel = postedIds.toSet()
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) {
Notification.Builder(this, chanId)
} else {
Notification.Builder(this)
}
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) } 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.clear()
postedIds.addAll(currentIds) notificationIdMap.clear()
// Post/update summary and ensure foreground // Create merged notification content
val maxSummaryLines = 5 val summaryLines = sessions.map { "${it.title}: ${it.status}" }
val truncated = summaryLines.size > maxSummaryLines val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions)
val displaySummaryLines = if (truncated) { ensureForeground(mergedNotification)
summaryLines.take(maxSummaryLines) + "...and ${summaryLines.size - maxSummaryLines} more"
} else {
summaryLines
}
val summary = createSummaryNotification(sessions.size, displaySummaryLines)
ensureForeground(summary)
} }
private fun clearAll() { private fun clearAll() {
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
nm?.cancel(SUMMARY_ID) nm?.cancel(NOTIFICATION_ID)
postedIds.forEach { id -> nm?.cancel(id) } postedIds.forEach { id -> nm?.cancel(id) }
postedIds.clear() postedIds.clear()
isFgStarted = false isFgStarted = false

View File

@@ -4,6 +4,9 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -16,6 +19,8 @@ class MainActivity: FlutterFragmentActivity() {
private lateinit var channel: MethodChannel private lateinit var channel: MethodChannel
private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS" private val ACTION_UPDATE_SESSIONS = "tech.lolli.toolbox.ACTION_UPDATE_SESSIONS"
private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION" private val ACTION_DISCONNECT_SESSION = "tech.lolli.toolbox.ACTION_DISCONNECT_SESSION"
private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS"
private var stopAllReceiver: BroadcastReceiver? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -92,6 +97,9 @@ class MainActivity: FlutterFragmentActivity() {
// Handle intent if launched via notification action // Handle intent if launched via notification action
handleActionIntent(intent) handleActionIntent(intent)
// Register broadcast receiver for stop all connections
setupStopAllReceiver()
} }
private fun reqPerm() { private fun reqPerm() {
@@ -141,4 +149,28 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
} }
private fun setupStopAllReceiver() {
stopAllReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_STOP_ALL_CONNECTIONS && ::channel.isInitialized) {
try {
channel.invokeMethod("stopAllConnections")
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to invoke stopAllConnections: ${e.message}")
}
}
}
}
val filter = IntentFilter(ACTION_STOP_ALL_CONNECTIONS)
registerReceiver(stopAllReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
stopAllReceiver?.let {
unregisterReceiver(it)
stopAllReceiver = null
}
}
} }

View File

@@ -77,8 +77,10 @@ abstract final class MethodChans {
} }
/// Register a handler for native -> Flutter callbacks. /// Register a handler for native -> Flutter callbacks.
/// Currently handles: `disconnectSession` with argument map {id: string} /// Currently handles:
static void registerHandler(Future<void> Function(String id) onDisconnect) { /// - `disconnectSession` with argument map {id: string}
/// - `stopAllConnections` with no arguments
static void registerHandler(Future<void> Function(String id) onDisconnect, [VoidCallback? onStopAll]) {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
switch (call.method) { switch (call.method) {
case 'disconnectSession': case 'disconnectSession':
@@ -88,6 +90,9 @@ abstract final class MethodChans {
await onDisconnect(id); await onDisconnect(id);
} }
return; return;
case 'stopAllConnections':
onStopAll?.call();
return;
default: default:
return; return;
} }

View File

@@ -51,12 +51,31 @@ abstract final class TermSessionManager {
static void init() { static void init() {
if (isAndroid) { if (isAndroid) {
MethodChans.registerHandler((id) async { MethodChans.registerHandler(
_entries[id]?.disconnect?.call(); (id) async {
}); _entries[id]?.disconnect?.call();
},
() {
// Stop all connections when notification "Stop All" is pressed
stopAllConnections();
},
);
} }
} }
/// Called when Android notification "Stop All" button is pressed
static void stopAllConnections() {
// Disconnect all sessions
final disconnectCallbacks = _entries.values.map((e) => e.disconnect).where((cb) => cb != null).toList();
for (final disconnect in disconnectCallbacks) {
disconnect!();
}
// Clear all entries
_entries.clear();
_activeId = null;
_sync();
}
/// Add a session record and push update to Android. /// Add a session record and push update to Android.
static void add({ static void add({
required String id, required String id,

View File

@@ -98,7 +98,7 @@ class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
), ),
), ),
Text( Text(
'${libL10n.success}: $successRate%', '${libL10n.success}: $successRate',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: stats.successRate >= 0.8 color: stats.successRate >= 0.8