Skip to content
Merged
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
43 changes: 43 additions & 0 deletions lib/platform/activity_relaunch.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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;
// Stops at a screen that refuses to close (`PopScope(canPop: false)`):
// a breathing session or an ECG capture in progress. `popUntil` pops
// without asking, and closing one of those mid-run would throw the
// recording away; whatever sits above it is still closed.
_navigator?.call()?.popUntil((route) =>
route.isFirst ||
route.popDisposition == RoutePopDisposition.doNotPop);
});
}

/// 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);
}
}
107 changes: 107 additions & 0 deletions test/activity_relaunch_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// #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('a screen that refuses to close stays; the one above it goes',
(tester) async {
await tester.pumpWidget(MaterialApp(
navigatorKey: navKey,
home: const Text('tab'),
));
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => const PopScope(canPop: false, child: Text('capture')),
));
await tester.pumpAndSettle();
navKey.currentState!
.push(MaterialPageRoute<void>(builder: (_) => const Text('help')));
await tester.pumpAndSettle();
ActivityRelaunch.listen(nav);

await fromNative(tester, 'relaunched');

expect(find.text('help'), findsNothing);
expect(find.text('capture'), findsOneWidget);
});

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