From 14fca0bf5db8af853d272643cb890b17acfa2cd0 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Tue, 10 Nov 2026 17:19:45 +0100 Subject: [PATCH] Close screens left open when Android relaunches the app On Android the Flutter engine outlives the Activity (it is cached and never destroyed, so background Bluetooth keeps running). Swiping the app away and opening it again from the launcher attached a new Activity to the same Navigator, so the app reopened inside whatever screen was on top, e.g. Settings (#650). On iOS the process ends and a relaunch starts on the saved tab with nothing above it. MainActivity now notes whether the engine already existed before ensureEngine, and when it did, with no savedInstanceState and the launcher's ACTION_MAIN/CATEGORY_LAUNCHER intent, sends `relaunched` on a new openstrap/activity channel. The shell handles it by popping to the first route. The saved tab is kept: restoring it is intentional (restoredShellTab). System restores, notification taps and widget taps are untouched; rotation and dark mode never reach onCreate (configChanges). --- .../openstrap/openstrap_edge/MainActivity.kt | 24 +++++- lib/app.dart | 8 ++ lib/platform/activity_relaunch.dart | 37 ++++++++ test/activity_relaunch_test.dart | 86 +++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 lib/platform/activity_relaunch.dart create mode 100644 test/activity_relaunch_test.dart diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt index d2ddce141..033e4bd42 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt @@ -4,6 +4,8 @@ import android.content.Context import android.content.Intent import android.os.Bundle import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.plugin.common.MethodChannel /** * Attaches to the single long-lived engine owned by [EdgeApplication] (created lazily @@ -27,19 +29,39 @@ class MainActivity : FlutterFragmentActivity() { companion object { @Volatile var activityAttached: Boolean = false + + /** Dart side: lib/platform/activity_relaunch.dart. */ + private const val ACTIVITY_CHANNEL = "openstrap/activity" } override fun getCachedEngineId(): String = EdgeApplication.ENGINE_ID override fun shouldDestroyEngineWithHost(): Boolean = false override fun onCreate(savedInstanceState: Bundle?) { + // Read BEFORE ensureEngine: true means this Activity attaches to a Dart side + // that outlived the previous one, screens and all (see the class doc). + val engineOutlivedActivity = + FlutterEngineCache.getInstance().get(EdgeApplication.ENGINE_ID) != null // The shared engine is created lazily now (EdgeApplication.ensureEngine). // getCachedEngineId() is consulted during super.onCreate, so the cache // entry must exist before it runs. - EdgeApplication.ensureEngine(applicationContext) + val engine = EdgeApplication.ensureEngine(applicationContext) activityAttached = true clearPendingHeadlessBoot() super.onCreate(savedInstanceState) + // Opened again from the launcher after the app was closed: the retained engine + // still has whatever screen was on top (issue #650), where iOS — whose process + // ends — would start fresh on the saved tab. Ask Dart to close those screens. + // Not on a restore (savedInstanceState: Android recreated us and expects the + // screen back), and not for notification/widget taps, whose own action routes + // them. Rotation and dark mode never get here (configChanges in the manifest). + if (engineOutlivedActivity && savedInstanceState == null && + intent?.action == Intent.ACTION_MAIN && + intent?.hasCategory(Intent.CATEGORY_LAUNCHER) == true + ) { + MethodChannel(engine.dartExecutor.binaryMessenger, ACTIVITY_CHANNEL) + .invokeMethod("relaunched", null) + } CompanionBridge.currentActivity = this // Re-arm CDM device-presence observation for an already-associated band // (idempotent; no-op below API 31 or when nothing is associated). diff --git a/lib/app.dart b/lib/app.dart index 33bcc2551..82576dc3e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,6 +10,7 @@ import 'data/db.dart'; import 'l10n/app_localizations.dart'; import 'notify/notification_service.dart'; import 'notify/tap_router.dart'; +import 'platform/activity_relaunch.dart'; import 'state/app_state.dart'; import 'state/clock_format.dart'; import 'state/locale_controller.dart'; @@ -539,6 +540,9 @@ class _ShellState extends State<_Shell> { _selection = ValueNotifier(_domain); _app!.navRequest.addListener(_consumeSoon); _app!.screenRequest.addListener(_consumeSoon); + // Android relaunch from the launcher: close screens left open on the + // engine that outlived the closed Activity (#650). + ActivityRelaunch.listen(_relaunchNavigator); // Cold launch from a tapped notification: the route may already be set // before this shell mounted (so the listener never fired). Consume it once // attached. @@ -551,6 +555,9 @@ class _ShellState extends State<_Shell> { }); } + NavigatorState? _relaunchNavigator() => + mounted ? Navigator.of(context) : null; + bool _pending = false; /// Both requests come from ONE tap and are written one after the other, so @@ -604,6 +611,7 @@ class _ShellState extends State<_Shell> { void dispose() { _app?.navRequest.removeListener(_consumeSoon); _app?.screenRequest.removeListener(_consumeSoon); + ActivityRelaunch.stop(_relaunchNavigator); _selection.dispose(); super.dispose(); } diff --git a/lib/platform/activity_relaunch.dart b/lib/platform/activity_relaunch.dart new file mode 100644 index 000000000..95e7a5a8a --- /dev/null +++ b/lib/platform/activity_relaunch.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Android: the launcher opened the app again after it was closed (issue #650). +/// +/// The Activity goes away when the app is swiped from recents, but the Flutter +/// engine is kept alive for background Bluetooth, so the Navigator still holds +/// whatever screen was on top: reopening landed back inside Settings. On iOS the +/// process ends and a relaunch starts on the saved tab with nothing above it. +/// `MainActivity.onCreate` sends `relaunched` only for that case, and this +/// closes the pushed screens to match. The tab itself is left alone: it is +/// restored on purpose (`restoredShellTab`). +class ActivityRelaunch { + static const channel = MethodChannel('openstrap/activity'); + + static NavigatorState? Function()? _navigator; + + /// Handle `relaunched` while [navigator] can answer; it returns null once its + /// owner is gone. Pass the same function to [stop] when the owner is disposed. + static void listen(NavigatorState? Function() navigator) { + _navigator = navigator; + channel.setMethodCallHandler((call) async { + if (call.method != 'relaunched') return; + _navigator?.call()?.popUntil((route) => route.isFirst); + }); + } + + /// Stops listening, unless a newer owner has called [listen] since: a + /// replaced owner's dispose runs after its successor's initState. + static void stop(NavigatorState? Function() navigator) { + // `!=`, not `identical`: two tear-offs of one instance's method are equal + // but not guaranteed identical. + if (_navigator != navigator) return; + _navigator = null; + channel.setMethodCallHandler(null); + } +} diff --git a/test/activity_relaunch_test.dart b/test/activity_relaunch_test.dart new file mode 100644 index 000000000..582c47af7 --- /dev/null +++ b/test/activity_relaunch_test.dart @@ -0,0 +1,86 @@ +// #650: on Android the Flutter engine outlives the Activity, so a screen left +// open (Settings, a detail page) was still on top when the app was opened +// again from the launcher. `MainActivity` sends `relaunched` on +// `openstrap/activity` for exactly that case; the Dart side closes the pushed +// screens and leaves the tab underneath alone, as an iOS cold start would. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/platform/activity_relaunch.dart'; + +void main() { + final navKey = GlobalKey(); + NavigatorState? nav() => navKey.currentState; + + Future fromNative(WidgetTester tester, String method) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + ActivityRelaunch.channel.name, + const StandardMethodCodec().encodeMethodCall(MethodCall(method)), + (_) {}, + ); + await tester.pumpAndSettle(); + } + + Future openTwoScreens(WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + navigatorKey: navKey, + home: const Text('tab'), + )); + for (final name in ['settings', 'detail']) { + navKey.currentState! + .push(MaterialPageRoute(builder: (_) => Text(name))); + await tester.pumpAndSettle(); + } + expect(find.text('detail'), findsOneWidget); + } + + tearDown(() => ActivityRelaunch.stop(nav)); + + testWidgets('relaunched closes every pushed screen, back to the tab', + (tester) async { + await openTwoScreens(tester); + ActivityRelaunch.listen(nav); + + await fromNative(tester, 'relaunched'); + + expect(find.text('tab'), findsOneWidget); + expect(find.text('settings'), findsNothing); + expect(find.text('detail'), findsNothing); + }); + + testWidgets('any other call leaves the screens alone', (tester) async { + await openTwoScreens(tester); + ActivityRelaunch.listen(nav); + + await fromNative(tester, 'something_else'); + + expect(find.text('detail'), findsOneWidget); + }); + + testWidgets('a disposed owner (null navigator) is a no-op, not a crash', + (tester) async { + await openTwoScreens(tester); + NavigatorState? gone() => null; + ActivityRelaunch.listen(gone); + + await fromNative(tester, 'relaunched'); + + expect(find.text('detail'), findsOneWidget); + ActivityRelaunch.stop(gone); + }); + + testWidgets("a replaced owner's stop does not silence its successor", + (tester) async { + await openTwoScreens(tester); + NavigatorState? old() => null; + ActivityRelaunch.listen(old); + ActivityRelaunch.listen(nav); // successor's initState + ActivityRelaunch.stop(old); // predecessor's later dispose + + await fromNative(tester, 'relaunched'); + + expect(find.text('tab'), findsOneWidget); + }); +}