From 05a927753f8829a7d744c6b00b6101e890e8e045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:04:53 +0800 Subject: [PATCH] feat: stop all servers in noti center (#901) --- .../tech/lolli/toolbox/ForegroundService.kt | 136 ++++++++---------- .../kotlin/tech/lolli/toolbox/MainActivity.kt | 32 +++++ lib/core/chan.dart | 9 +- lib/data/ssh/session_manager.dart | 25 +++- lib/view/page/server/connection_stats.dart | 2 +- 5 files changed, 124 insertions(+), 80 deletions(-) diff --git a/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt b/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt index f5b662c6..9139a243 100644 --- a/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt +++ b/android/app/src/main/kotlin/tech/lolli/toolbox/ForegroundService.kt @@ -16,8 +16,7 @@ class ForegroundService : Service() { var isRunning: Boolean = false } private val chanId = "ForegroundServiceChannel" - private val GROUP_KEY = "ssh_sessions_group" - private val SUMMARY_ID = 1000 + private val NOTIFICATION_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" @@ -70,6 +69,9 @@ class ForegroundService : Service() { return when (action) { ACTION_STOP_FOREGROUND -> { + // Notify Flutter to stop all connections before stopping service + val stopAllIntent = Intent("tech.lolli.toolbox.STOP_ALL_CONNECTIONS") + sendBroadcast(stopAllIntent) clearAll() stopForegroundService() START_NOT_STICKY @@ -81,7 +83,7 @@ class ForegroundService : Service() { } else -> { // Default bring up foreground with placeholder - ensureForeground(createSummaryNotification(0, emptyList())) + ensureForeground(createMergedNotification(0, emptyList(), emptyList())) START_STICKY } } @@ -118,18 +120,19 @@ class ForegroundService : Service() { private fun ensureForeground(notification: Notification) { try { if (!isFgStarted) { - startForeground(SUMMARY_ID, notification) + startForeground(NOTIFICATION_ID, notification) isFgStarted = true } else { val nm = getSystemService(NotificationManager::class.java) - nm?.notify(SUMMARY_ID, notification) + nm?.notify(NOTIFICATION_ID, notification) } } catch (e: Exception) { logError("Failed to start/update foreground", e) } } - private fun createSummaryNotification(count: Int, lines: List): Notification { + + private fun createMergedNotification(count: Int, lines: List, sessions: List): Notification { val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE @@ -143,21 +146,56 @@ class ForegroundService : Service() { Notification.Builder(this) } - val inbox = Notification.InboxStyle() - lines.forEach { inbox.addLine(it) } + // Use the earliest session's start time for chronometer + val earliestStartTime = sessions.minOfOrNull { it.startWhen } ?: System.currentTimeMillis() - return builder - .setContentTitle("SSH sessions: $count active") - .setContentText(if (lines.isNotEmpty()) lines.first() else "Running") + val title = when { + count == 0 -> "Server Box" + 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) - .setStyle(inbox) + .setWhen(earliestStartTime) + .setUsesChronometer(true) .setOngoing(true) .setOnlyAlertOnce(true) - .setGroup(GROUP_KEY) - .setGroupSummary(true) .setContentIntent(pendingIntent) - .addAction(android.R.drawable.ic_delete, "Stop", stopPending) - .build() + .addAction(android.R.drawable.ic_delete, "Stop All", stopPending) + + if (style != null) { + notification.setStyle(style) + } + + return notification.build() } private fun handleUpdateSessions(payload: String) { @@ -192,71 +230,21 @@ class ForegroundService : Service() { return } - // Build per-session notifications - val currentIds = mutableSetOf() - val summaryLines = mutableListOf() - 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 + // Cancel any existing individual notifications (we only show merged notification now) + val toCancel = postedIds.toSet() 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) + notificationIdMap.clear() - // 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) + // Create merged notification content + val summaryLines = sessions.map { "${it.title}: ${it.status}" } + val mergedNotification = createMergedNotification(sessions.size, summaryLines, sessions) + ensureForeground(mergedNotification) } private fun clearAll() { val nm = getSystemService(NotificationManager::class.java) - nm?.cancel(SUMMARY_ID) + nm?.cancel(NOTIFICATION_ID) postedIds.forEach { id -> nm?.cancel(id) } postedIds.clear() isFgStarted = false diff --git a/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt b/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt index 3e8f714f..33e6aecf 100644 --- a/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt +++ b/android/app/src/main/kotlin/tech/lolli/toolbox/MainActivity.kt @@ -4,6 +4,9 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import io.flutter.embedding.android.FlutterFragmentActivity @@ -16,6 +19,8 @@ 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" + private val ACTION_STOP_ALL_CONNECTIONS = "tech.lolli.toolbox.STOP_ALL_CONNECTIONS" + private var stopAllReceiver: BroadcastReceiver? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -92,6 +97,9 @@ class MainActivity: FlutterFragmentActivity() { // Handle intent if launched via notification action handleActionIntent(intent) + + // Register broadcast receiver for stop all connections + setupStopAllReceiver() } 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 + } + } } diff --git a/lib/core/chan.dart b/lib/core/chan.dart index a86770e5..274e16bb 100644 --- a/lib/core/chan.dart +++ b/lib/core/chan.dart @@ -77,8 +77,10 @@ abstract final class MethodChans { } /// Register a handler for native -> Flutter callbacks. - /// Currently handles: `disconnectSession` with argument map {id: string} - static void registerHandler(Future Function(String id) onDisconnect) { + /// Currently handles: + /// - `disconnectSession` with argument map {id: string} + /// - `stopAllConnections` with no arguments + static void registerHandler(Future Function(String id) onDisconnect, [VoidCallback? onStopAll]) { _channel.setMethodCallHandler((call) async { switch (call.method) { case 'disconnectSession': @@ -88,6 +90,9 @@ abstract final class MethodChans { await onDisconnect(id); } return; + case 'stopAllConnections': + onStopAll?.call(); + return; default: return; } diff --git a/lib/data/ssh/session_manager.dart b/lib/data/ssh/session_manager.dart index 044baa5b..0ef5b5af 100644 --- a/lib/data/ssh/session_manager.dart +++ b/lib/data/ssh/session_manager.dart @@ -51,12 +51,31 @@ abstract final class TermSessionManager { static void init() { if (isAndroid) { - MethodChans.registerHandler((id) async { - _entries[id]?.disconnect?.call(); - }); + MethodChans.registerHandler( + (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. static void add({ required String id, diff --git a/lib/view/page/server/connection_stats.dart b/lib/view/page/server/connection_stats.dart index de1a55bb..80705d3e 100644 --- a/lib/view/page/server/connection_stats.dart +++ b/lib/view/page/server/connection_stats.dart @@ -98,7 +98,7 @@ class _ConnectionStatsPageState extends State { ), ), Text( - '${libL10n.success}: $successRate%', + '${libL10n.success}: $successRate', style: TextStyle( fontSize: 16, color: stats.successRate >= 0.8