mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
feat: stop all servers in noti center (#901)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,31 @@ abstract final class TermSessionManager {
|
|||||||
|
|
||||||
static void init() {
|
static void init() {
|
||||||
if (isAndroid) {
|
if (isAndroid) {
|
||||||
MethodChans.registerHandler((id) async {
|
MethodChans.registerHandler(
|
||||||
|
(id) async {
|
||||||
_entries[id]?.disconnect?.call();
|
_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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user