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); + }); +}