Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
37 changes: 37 additions & 0 deletions lib/platform/activity_relaunch.dart
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Launcher relaunch freezes during active capture

When the launcher relaunch arrives while CalmBreathing is running or ECG capture is busy, popUntil repeatedly tries to pop the guarded route, but its PopScope rejects the pop; the loop cannot advance while the route remains topmost, so the UI isolate hangs and the app freezes.

Close guarded screens asynchronously before popping the remaining routes, or otherwise make relaunch stack clearing advance without retrying a pop that PopScope rejects.

Prompt for AI agents
In `lib/platform/activity_relaunch.dart` at line 24:

**Launcher relaunch freezes during active capture**

When the launcher relaunch arrives while CalmBreathing is running or ECG capture is busy, `popUntil` repeatedly tries to pop the guarded route, but its `PopScope` rejects the pop; the loop cannot advance while the route remains topmost, so the UI isolate hangs and the app freezes.

Close guarded screens asynchronously before popping the remaining routes, or otherwise make relaunch stack clearing advance without retrying a pop that `PopScope` rejects.

});
}

/// 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);
}
}
86 changes: 86 additions & 0 deletions test/activity_relaunch_test.dart
Original file line number Diff line number Diff line change
@@ -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>();
NavigatorState? nav() => navKey.currentState;

Future<void> fromNative(WidgetTester tester, String method) async {
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
ActivityRelaunch.channel.name,
const StandardMethodCodec().encodeMethodCall(MethodCall(method)),
(_) {},
);
await tester.pumpAndSettle();
}

Future<void> openTwoScreens(WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
navigatorKey: navKey,
home: const Text('tab'),
));
for (final name in ['settings', 'detail']) {
navKey.currentState!
.push(MaterialPageRoute<void>(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);
});
}