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.
integration/290-306-335
Strycher 1 day ago
parent c8b36b056d
commit c7fc4dca7b

@ -2,13 +2,32 @@ package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
private val appLifecycleChannelName = "meshcore_open/app_lifecycle"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
usbFunctions.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() { override fun onDestroy() {

@ -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<bool> moveToBackground() async {
if (!PlatformInfo.isAndroid) return false;
try {
return await _channel.invokeMethod<bool>('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;
}
}
}

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/ui_view_state_service.dart'; import '../services/ui_view_state_service.dart';
import '../utils/app_backgrounder.dart';
import 'quick_switch_bar.dart'; import 'quick_switch_bar.dart';
/// Shared shell for the primary views (Contacts / Channels / Map). /// Shared shell for the primary views (Contacts / Channels / Map).
@ -69,8 +69,8 @@ class _AppShellState extends State<AppShell> {
/// System back, in priority order: /// System back, in priority order:
/// 1. an open drawer closes, /// 1. an open drawer closes,
/// 2. on a detail screen, pop back to the list it came from, /// 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 /// 3. on a primary view, send the app to the background so Android
/// home screen or the previous app. /// returns to the home screen or the previous app.
/// ///
/// Step 3 must NOT pop, even though the route below can be popped. The /// 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 /// primary views sit on top of the scanner, so popping would dump a
@ -79,7 +79,7 @@ class _AppShellState extends State<AppShell> {
/// ///
/// A primary view is one carrying the bottom bar; a detail screen (a channel /// A primary view is one carrying the bottom bar; a detail screen (a channel
/// chat) has no [selectedIndex] and is genuinely pushed. /// chat) has no [selectedIndex] and is genuinely pushed.
void _handleBack() { Future<void> _handleBack() async {
final scaffold = _scaffoldKey.currentState; final scaffold = _scaffoldKey.currentState;
if (scaffold?.isDrawerOpen ?? false) { if (scaffold?.isDrawerOpen ?? false) {
scaffold!.closeDrawer(); scaffold!.closeDrawer();
@ -92,7 +92,11 @@ class _AppShellState extends State<AppShell> {
navigator.pop(); navigator.pop();
return; 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 @override

Loading…
Cancel
Save

Powered by TurnKey Linux.