From c7fc4dca7b5769adcf99bde83173cf276bf79650 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 02:22:34 -0400 Subject: [PATCH] fix(#291): background the app on back instead of killing it Back at the root was calling SystemNavigator.pop(). That is not "background the app": on Android it calls finish() on the activity, which tears down the Flutter engine and drops the radio connection. Backing out and reopening therefore landed on a disconnected radio needing a fresh connect. Wrong call for the job, and worse than the behaviour it replaced. Backgrounding without finishing needs moveTaskToBack, which has no Flutter equivalent, so it goes over a method channel: - MainActivity exposes meshcore_open/app_lifecycle with a moveTaskToBack method, alongside the existing USB channel. - AppBackgrounder wraps it and is a no-op off Android, where programmatic backgrounding either does not apply (desktop windows close by their own chrome) or is forbidden (iOS). On those platforms back at the root stays unhandled rather than doing something destructive. - AppShell awaits it instead of calling SystemNavigator.pop(). The activity stays alive, so the connection survives and reopening returns to where the user was. flutter analyze clean, dart format clean, 469 tests pass. --- .../meshcore/meshcore_open/MainActivity.kt | 19 +++++++++++ lib/utils/app_backgrounder.dart | 33 +++++++++++++++++++ lib/widgets/app_shell.dart | 14 +++++--- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 lib/utils/app_backgrounder.dart diff --git a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt index 9022c8b..27136ea 100644 --- a/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt +++ b/android/app/src/main/kotlin/com/meshcore/meshcore_open/MainActivity.kt @@ -2,13 +2,32 @@ package com.meshcore.meshcore_open import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } + private val appLifecycleChannelName = "meshcore_open/app_lifecycle" + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) usbFunctions.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + appLifecycleChannelName + ).setMethodCallHandler { call, result -> + when (call.method) { + // Send the task to the background WITHOUT finishing the + // activity. SystemNavigator.pop() calls finish(), which tears + // down the Flutter engine and drops the radio connection, so + // reopening the app lands on a disconnected radio. + "moveTaskToBack" -> { + result.success(moveTaskToBack(true)) + } + else -> result.notImplemented() + } + } } override fun onDestroy() { diff --git a/lib/utils/app_backgrounder.dart b/lib/utils/app_backgrounder.dart new file mode 100644 index 0000000..2a01908 --- /dev/null +++ b/lib/utils/app_backgrounder.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; + +import 'platform_info.dart'; + +/// Sends the app to the background without tearing it down. +/// +/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the +/// activity, which destroys the Flutter engine and drops the radio connection, +/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the +/// activity alive and simply hands focus back to whatever was in front before. +class AppBackgrounder { + static const MethodChannel _channel = MethodChannel( + 'meshcore_open/app_lifecycle', + ); + + /// Returns true when the app was actually backgrounded. + /// + /// Android only. Desktop windows are closed by their own chrome and iOS + /// forbids programmatic backgrounding, so elsewhere this is a no-op and the + /// caller should leave the press unhandled rather than act on it. + static Future moveToBackground() async { + if (!PlatformInfo.isAndroid) return false; + try { + return await _channel.invokeMethod('moveTaskToBack') ?? false; + } on PlatformException catch (_) { + // Surface nothing to the user: the fallback is simply that back does + // nothing at the root, which is the pre-existing behaviour. + return false; + } on MissingPluginException catch (_) { + return false; + } + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index b95ba8d..d07c3ec 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../services/ui_view_state_service.dart'; +import '../utils/app_backgrounder.dart'; import 'quick_switch_bar.dart'; /// Shared shell for the primary views (Contacts / Channels / Map). @@ -69,8 +69,8 @@ class _AppShellState extends State { /// System back, in priority order: /// 1. an open drawer closes, /// 2. on a detail screen, pop back to the list it came from, - /// 3. on a primary view, hand back to the OS so Android returns to the - /// home screen or the previous app. + /// 3. on a primary view, send the app to the background so Android + /// returns to the home screen or the previous app. /// /// Step 3 must NOT pop, even though the route below can be popped. The /// primary views sit on top of the scanner, so popping would dump a @@ -79,7 +79,7 @@ class _AppShellState extends State { /// /// A primary view is one carrying the bottom bar; a detail screen (a channel /// chat) has no [selectedIndex] and is genuinely pushed. - void _handleBack() { + Future _handleBack() async { final scaffold = _scaffoldKey.currentState; if (scaffold?.isDrawerOpen ?? false) { scaffold!.closeDrawer(); @@ -92,7 +92,11 @@ class _AppShellState extends State { navigator.pop(); return; } - SystemNavigator.pop(); + + // Background, do NOT finish. SystemNavigator.pop() would call finish() on + // the activity, tearing down the Flutter engine and dropping the radio + // connection, so reopening the app would show a disconnected radio. + await AppBackgrounder.moveToBackground(); } @override