Unverified Commit a2803461 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add `AppLifecycleListener`, with support for application exit handling (#123274)

## Description

This adds `AppLifecycleListener`, a class for listening to changes in the application lifecycle, and responding to requests to exit the application.

It depends on changes in the Engine that add new lifecycle states: https://github.com/flutter/engine/pull/42418

Here's a diagram for the lifecycle states. I'll add a similar diagram to the documentation for these classes.

![Application Lifecycle Diagram](https://github.com/flutter/flutter/assets/8867023/f6937002-cb93-4ab9-a221-25de2c45cf0e)

## Related Issues
 - https://github.com/flutter/flutter/issues/30735

## Tests
- Added tests for new lifecycle value, as well as for the `AppLifecycleListener` itself.
parent f2351f61
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Flutter code sample for [AppLifecycleListener].
void main() {
runApp(const AppLifecycleListenerExample());
}
class AppLifecycleListenerExample extends StatelessWidget {
const AppLifecycleListenerExample({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: AppLifecycleDisplay()),
);
}
}
class AppLifecycleDisplay extends StatefulWidget {
const AppLifecycleDisplay({super.key});
@override
State<AppLifecycleDisplay> createState() => _AppLifecycleDisplayState();
}
class _AppLifecycleDisplayState extends State<AppLifecycleDisplay> {
late final AppLifecycleListener _listener;
final ScrollController _scrollController = ScrollController();
final List<String> _states = <String>[];
late AppLifecycleState? _state;
@override
void initState() {
super.initState();
_state = SchedulerBinding.instance.lifecycleState;
_listener = AppLifecycleListener(
onShow: () => _handleTransition('show'),
onResume: () => _handleTransition('resume'),
onHide: () => _handleTransition('hide'),
onInactive: () => _handleTransition('inactive'),
onPause: () => _handleTransition('pause'),
onDetach: () => _handleTransition('detach'),
onRestart: () => _handleTransition('restart'),
// This fires for each state change. Callbacks above fire only for
// specific state transitions.
onStateChange: _handleStateChange,
);
if (_state != null) {
_states.add(_state!.name);
}
}
@override
void dispose() {
_listener.dispose();
super.dispose();
}
void _handleTransition(String name) {
setState(() {
_states.add(name);
});
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
void _handleStateChange(AppLifecycleState state) {
setState(() {
_state = state;
});
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: 300,
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
children: <Widget>[
Text('Current State: ${_state ?? 'Not initialized yet'}'),
const SizedBox(height: 30),
Text('State History:\n ${_states.join('\n ')}'),
],
),
),
),
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Flutter code sample for [AppLifecycleListener].
void main() {
runApp(const AppLifecycleListenerExample());
}
class AppLifecycleListenerExample extends StatelessWidget {
const AppLifecycleListenerExample({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: ApplicationExitControl()),
);
}
}
class ApplicationExitControl extends StatefulWidget {
const ApplicationExitControl({super.key});
@override
State<ApplicationExitControl> createState() => _ApplicationExitControlState();
}
class _ApplicationExitControlState extends State<ApplicationExitControl> {
late final AppLifecycleListener _listener;
bool _shouldExit = false;
String _lastExitResponse = 'No exit requested yet';
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(
onExitRequested: _handleExitRequest,
);
}
@override
void dispose() {
_listener.dispose();
super.dispose();
}
Future<void> _quit() async {
final AppExitType exitType = _shouldExit ? AppExitType.required : AppExitType.cancelable;
await ServicesBinding.instance.exitApplication(exitType);
}
Future<AppExitResponse> _handleExitRequest() async {
final AppExitResponse response = _shouldExit ? AppExitResponse.exit : AppExitResponse.cancel;
setState(() {
_lastExitResponse = 'App responded ${response.name} to exit request';
});
return response;
}
void _radioChanged(bool? value) {
value ??= true;
if (_shouldExit == value) {
return;
}
setState(() {
_shouldExit = value!;
});
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: 300,
child: IntrinsicHeight(
child: Column(
children: <Widget>[
RadioListTile<bool>(
title: const Text('Do Not Allow Exit'),
groupValue: _shouldExit,
value: false,
onChanged: _radioChanged,
),
RadioListTile<bool>(
title: const Text('Allow Exit'),
groupValue: _shouldExit,
value: true,
onChanged: _radioChanged,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: _quit,
child: const Text('Quit'),
),
const SizedBox(height: 30),
Text('Exit Request: $_lastExitResponse'),
],
),
),
),
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_api_samples/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('AppLifecycleListener example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.AppLifecycleListenerExample(),
);
expect(find.textContaining('Current State:'), findsOneWidget);
expect(find.textContaining('State History:'), findsOneWidget);
});
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_api_samples/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('AppLifecycleListener example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.AppLifecycleListenerExample(),
);
expect(find.text('Do Not Allow Exit'), findsOneWidget);
expect(find.text('Allow Exit'), findsOneWidget);
expect(find.text('Quit'), findsOneWidget);
expect(find.textContaining('Exit Request:'), findsOneWidget);
await tester.tap(find.text('Quit'));
await tester.pump();
// Responding to the the quit request happens in a Future that we don't have
// visibility for, so to avoid a flaky test with a delay, we just check to
// see if the request string prefix is still there, rather than the request
// response string. Testing it wasn't worth exposing a Completer in the
// example code.
expect(find.textContaining('Exit Request:'), findsOneWidget);
});
}
...@@ -33,14 +33,14 @@ void main() { ...@@ -33,14 +33,14 @@ void main() {
await setAppLifeCycleState(AppLifecycleState.paused); await setAppLifeCycleState(AppLifecycleState.paused);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await setAppLifeCycleState(AppLifecycleState.resumed); // Can't look for paused text here because rendering is paused.
await tester.pumpAndSettle();
expect(find.text('state is: AppLifecycleState.paused'), findsOneWidget);
await setAppLifeCycleState(AppLifecycleState.detached); await setAppLifeCycleState(AppLifecycleState.inactive);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('state is: AppLifecycleState.inactive'), findsNWidgets(2));
await setAppLifeCycleState(AppLifecycleState.resumed); await setAppLifeCycleState(AppLifecycleState.resumed);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('state is: AppLifecycleState.detached'), findsOneWidget); expect(find.text('state is: AppLifecycleState.resumed'), findsNWidgets(2));
}); });
} }
...@@ -371,8 +371,9 @@ mixin SchedulerBinding on BindingBase { ...@@ -371,8 +371,9 @@ mixin SchedulerBinding on BindingBase {
/// This is set by [handleAppLifecycleStateChanged] when the /// This is set by [handleAppLifecycleStateChanged] when the
/// [SystemChannels.lifecycle] notification is dispatched. /// [SystemChannels.lifecycle] notification is dispatched.
/// ///
/// The preferred way to watch for changes to this value is using /// The preferred ways to watch for changes to this value are using
/// [WidgetsBindingObserver.didChangeAppLifecycleState]. /// [WidgetsBindingObserver.didChangeAppLifecycleState], or through an
/// [AppLifecycleListener] object.
AppLifecycleState? get lifecycleState => _lifecycleState; AppLifecycleState? get lifecycleState => _lifecycleState;
AppLifecycleState? _lifecycleState; AppLifecycleState? _lifecycleState;
...@@ -392,19 +393,18 @@ mixin SchedulerBinding on BindingBase { ...@@ -392,19 +393,18 @@ mixin SchedulerBinding on BindingBase {
@protected @protected
@mustCallSuper @mustCallSuper
void handleAppLifecycleStateChanged(AppLifecycleState state) { void handleAppLifecycleStateChanged(AppLifecycleState state) {
if (lifecycleState == state) {
return;
}
_lifecycleState = state; _lifecycleState = state;
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
_setFramesEnabledState(true); _setFramesEnabledState(true);
case AppLifecycleState.hidden:
case AppLifecycleState.paused: case AppLifecycleState.paused:
case AppLifecycleState.detached: case AppLifecycleState.detached:
_setFramesEnabledState(false); _setFramesEnabledState(false);
// ignore: no_default_cases
default:
// TODO(gspencergoog): Remove this and replace with real cases once
// engine change rolls into framework.
break;
} }
} }
......
...@@ -243,28 +243,104 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { ...@@ -243,28 +243,104 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
/// ///
/// Once the [lifecycleState] is populated through any means (including this /// Once the [lifecycleState] is populated through any means (including this
/// method), this method will do nothing. This is because the /// method), this method will do nothing. This is because the
/// [dart:ui.PlatformDispatcher.initialLifecycleState] may already be /// [dart:ui.PlatformDispatcher.initialLifecycleState] may already be stale
/// stale and it no longer makes sense to use the initial state at dart vm /// and it no longer makes sense to use the initial state at dart vm startup
/// startup as the current state anymore. /// as the current state anymore.
/// ///
/// The latest state should be obtained by subscribing to /// The latest state should be obtained by subscribing to
/// [WidgetsBindingObserver.didChangeAppLifecycleState]. /// [WidgetsBindingObserver.didChangeAppLifecycleState].
@protected @protected
void readInitialLifecycleStateFromNativeWindow() { void readInitialLifecycleStateFromNativeWindow() {
if (lifecycleState != null) { if (lifecycleState != null || platformDispatcher.initialLifecycleState.isEmpty) {
return; return;
} }
final AppLifecycleState? state = _parseAppLifecycleMessage(platformDispatcher.initialLifecycleState); _handleLifecycleMessage(platformDispatcher.initialLifecycleState);
if (state != null) {
handleAppLifecycleStateChanged(state);
}
} }
Future<String?> _handleLifecycleMessage(String? message) async { Future<String?> _handleLifecycleMessage(String? message) async {
handleAppLifecycleStateChanged(_parseAppLifecycleMessage(message!)!); final AppLifecycleState? state = _parseAppLifecycleMessage(message!);
final List<AppLifecycleState> generated = _generateStateTransitions(lifecycleState, state!);
generated.forEach(handleAppLifecycleStateChanged);
return null; return null;
} }
List<AppLifecycleState> _generateStateTransitions(AppLifecycleState? previousState, AppLifecycleState state) {
if (previousState == state) {
return const <AppLifecycleState>[];
}
if (previousState == AppLifecycleState.paused && state == AppLifecycleState.detached) {
// Handle the wrap-around from paused to detached
return const <AppLifecycleState>[
AppLifecycleState.detached,
];
}
final List<AppLifecycleState> stateChanges = <AppLifecycleState>[];
if (previousState == null) {
// If there was no previous state, just jump directly to the new state.
stateChanges.add(state);
} else {
final int previousStateIndex = AppLifecycleState.values.indexOf(previousState);
final int stateIndex = AppLifecycleState.values.indexOf(state);
assert(previousStateIndex != -1, 'State $previousState missing in stateOrder array');
assert(stateIndex != -1, 'State $state missing in stateOrder array');
if (previousStateIndex > stateIndex) {
for (int i = stateIndex; i < previousStateIndex; ++i) {
stateChanges.insert(0, AppLifecycleState.values[i]);
}
} else {
for (int i = previousStateIndex + 1; i <= stateIndex; ++i) {
stateChanges.add(AppLifecycleState.values[i]);
}
}
}
assert((){
AppLifecycleState? starting = previousState;
for (final AppLifecycleState ending in stateChanges) {
if (!_debugVerifyLifecycleChange(starting, ending)) {
return false;
}
starting = ending;
}
return true;
}(), 'Invalid lifecycle state transition generated from $previousState to $state (generated $stateChanges)');
return stateChanges;
}
static bool _debugVerifyLifecycleChange(AppLifecycleState? starting, AppLifecycleState ending) {
if (starting == null) {
// Any transition from null is fine, since it is initializing the state.
return true;
}
if (starting == ending) {
// Any transition to itself shouldn't happen.
return false;
}
switch (starting) {
case AppLifecycleState.detached:
if (ending == AppLifecycleState.resumed || ending == AppLifecycleState.paused) {
return true;
}
case AppLifecycleState.resumed:
// Can't go from resumed to detached directly (must go through paused).
if (ending == AppLifecycleState.inactive) {
return true;
}
case AppLifecycleState.inactive:
if (ending == AppLifecycleState.resumed || ending == AppLifecycleState.hidden) {
return true;
}
case AppLifecycleState.hidden:
if (ending == AppLifecycleState.inactive || ending == AppLifecycleState.paused) {
return true;
}
case AppLifecycleState.paused:
if (ending == AppLifecycleState.hidden || ending == AppLifecycleState.detached) {
return true;
}
}
return false;
}
Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async { Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
final String method = methodCall.method; final String method = methodCall.method;
assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit'); assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit');
...@@ -359,7 +435,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { ...@@ -359,7 +435,6 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
/// ///
/// * [WidgetsBindingObserver.didRequestAppExit] for a handler you can /// * [WidgetsBindingObserver.didRequestAppExit] for a handler you can
/// override on a [WidgetsBindingObserver] to receive exit requests. /// override on a [WidgetsBindingObserver] to receive exit requests.
@mustCallSuper
Future<ui.AppExitResponse> exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async { Future<ui.AppExitResponse> exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async {
final Map<String, Object?>? result = await SystemChannels.platform.invokeMethod<Map<String, Object?>>( final Map<String, Object?>? result = await SystemChannels.platform.invokeMethod<Map<String, Object?>>(
'System.exitApplication', 'System.exitApplication',
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'binding.dart';
/// A callback type that is used by [AppLifecycleListener.onExitRequested] to
/// ask the application if it wants to cancel application termination or not.
typedef AppExitRequestCallback = Future<AppExitResponse> Function();
/// A listener that can be used to listen to changes in the application
/// lifecycle.
///
/// To listen for requests for the application to exit, and to decide whether or
/// not the application should exit when requested, create an
/// [AppLifecycleListener] and set the [onExitRequested] callback.
///
/// To listen for changes in the application lifecycle state, define an
/// [onStateChange] callback. See the [AppLifecycleState] enum for details on
/// the various states.
///
/// The [onStateChange] callback is called for each state change, and the
/// individual state transitions ([onResume], [onInactive], etc.) are also
/// called if the state transition they represent occurs.
///
/// State changes will occur in accordance with the state machine described by
/// this diagram:
///
/// ![Diagram of the application lifecycle defined by the AppLifecycleState enum](
/// https://flutter.github.io/assets-for-api-docs/assets/dart-ui/app_lifecycle.png)
///
/// The initial state of the state machine is the [AppLifecycleState.detached]
/// state, and the arrows describe valid state transitions. Transitions in blue
/// are transitions that only happen on iOS and Android.
///
/// {@tool dartpad}
/// This example shows how an application can listen to changes in the
/// application state.
///
/// ** See code in examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// This example shows how an application can optionally decide to abort a
/// request for exiting instead of obeying the request.
///
/// ** See code in examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart **
/// {@end-tool}
///
/// See also:
///
/// * [ServicesBinding.exitApplication] for a function to call that will request
/// that the application exits.
/// * [WidgetsBindingObserver.didRequestAppExit] for the handler which this
/// class uses to receive exit requests.
/// * [WidgetsBindingObserver.didChangeAppLifecycleState] for the handler which
/// this class uses to receive lifecycle state changes.
class AppLifecycleListener with WidgetsBindingObserver, Diagnosticable {
/// Creates an [AppLifecycleListener].
AppLifecycleListener({
WidgetsBinding? binding,
this.onResume,
this.onInactive,
this.onHide,
this.onShow,
this.onPause,
this.onRestart,
this.onDetach,
this.onExitRequested,
this.onStateChange,
}) : binding = binding ?? WidgetsBinding.instance,
_lifecycleState = (binding ?? WidgetsBinding.instance).lifecycleState {
this.binding.addObserver(this);
}
AppLifecycleState? _lifecycleState;
/// The [WidgetsBinding] to listen to for application lifecycle events.
///
/// Typically, this is set to [WidgetsBinding.instance], but may be
/// substituted for testing or other specialized bindings.
///
/// Defaults to [WidgetsBinding.instance].
final WidgetsBinding binding;
/// Called anytime the state changes, passing the new state.
final ValueChanged<AppLifecycleState>? onStateChange;
/// A callback that is called when the application loses input focus.
///
/// On mobile platforms, this can be during a phone call or when a system
/// dialog is visible.
///
/// On desktop platforms, this is when all views in an application have lost
/// input focus but at least one view of the application is still visible.
///
/// On the web, this is when the window (or tab) has lost input focus.
final VoidCallback? onInactive;
/// A callback that is called when a view in the application gains input
/// focus.
///
/// A call to this callback indicates that the application is entering a state
/// where it is visible, active, and accepting user input.
final VoidCallback? onResume;
/// A callback that is called when the application is hidden.
///
/// On mobile platforms, this is usually just before the application is
/// replaced by another application in the foreground.
///
/// On desktop platforms, this is just before the application is hidden by
/// being minimized or otherwise hiding all views of the application.
///
/// On the web, this is just before a window (or tab) is hidden.
final VoidCallback? onHide;
/// A callback that is called when the application is shown.
///
/// On mobile platforms, this is usually just before the application replaces
/// another application in the foreground.
///
/// On desktop platforms, this is just before the application is shown after
/// being minimized or otherwise made to show at least one view of the
/// application.
///
/// On the web, this is just before a window (or tab) is shown.
final VoidCallback? onShow;
/// A callback that is called when the application is paused.
///
/// On mobile platforms, this happens right before the application is replaced
/// by another application.
///
/// On desktop platforms and the web, this function is not called.
final VoidCallback? onPause;
/// A callback that is called when the application is resumed after being
/// paused.
///
/// On mobile platforms, this happens just before this application takes over
/// as the active application.
///
/// On desktop platforms and the web, this function is not called.
final VoidCallback? onRestart;
/// A callback used to ask the application if it will allow exiting the
/// application for cases where the exit is cancelable.
///
/// Exiting the application isn't always cancelable, but when it is, this
/// function will be called before exit occurs.
///
/// Responding [AppExitResponse.exit] will continue termination, and
/// responding [AppExitResponse.cancel] will cancel it. If termination is not
/// canceled, the application will immediately exit.
final AppExitRequestCallback? onExitRequested;
/// A callback that is called when an application has exited, and detached all
/// host views from the engine.
///
/// This callback is only called on iOS and Android.
final VoidCallback? onDetach;
bool _debugDisposed = false;
/// Call when the listener is no longer in use.
///
/// Do not use the object after calling [dispose].
///
/// Subclasses must call this method in their overridden [dispose], if any.
@mustCallSuper
void dispose() {
assert(_debugAssertNotDisposed());
binding.removeObserver(this);
assert(() {
_debugDisposed = true;
return true;
}());
}
bool _debugAssertNotDisposed() {
assert(() {
if (_debugDisposed) {
throw FlutterError(
'A $runtimeType was used after being disposed.\n'
'Once you have called dispose() on a $runtimeType, it '
'can no longer be used.',
);
}
return true;
}());
return true;
}
@override
Future<AppExitResponse> didRequestAppExit() async {
assert(_debugAssertNotDisposed());
if (onExitRequested == null) {
return AppExitResponse.exit;
}
return onExitRequested!();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
assert(_debugAssertNotDisposed());
final AppLifecycleState? previousState = _lifecycleState;
if (state == previousState) {
// Transitioning to the same state twice doesn't produce any
// notifications (but also won't actually occur).
return;
}
_lifecycleState = state;
switch (state) {
case AppLifecycleState.resumed:
assert(previousState == null || previousState == AppLifecycleState.inactive || previousState == AppLifecycleState.detached, 'Invalid state transition from $previousState to $state');
onResume?.call();
case AppLifecycleState.inactive:
assert(previousState == null || previousState == AppLifecycleState.hidden || previousState == AppLifecycleState.resumed, 'Invalid state transition from $previousState to $state');
if (previousState == AppLifecycleState.hidden) {
onShow?.call();
} else if (previousState == null || previousState == AppLifecycleState.resumed) {
onInactive?.call();
}
case AppLifecycleState.hidden:
assert(previousState == null || previousState == AppLifecycleState.paused || previousState == AppLifecycleState.inactive, 'Invalid state transition from $previousState to $state');
if (previousState == AppLifecycleState.paused) {
onRestart?.call();
} else if (previousState == null || previousState == AppLifecycleState.inactive) {
onHide?.call();
}
case AppLifecycleState.paused:
assert(previousState == null || previousState == AppLifecycleState.hidden, 'Invalid state transition from $previousState to $state');
if (previousState == null || previousState == AppLifecycleState.hidden) {
onPause?.call();
}
case AppLifecycleState.detached:
assert(previousState == null || previousState == AppLifecycleState.paused, 'Invalid state transition from $previousState to $state');
onDetach?.call();
}
// At this point, it can't be null anymore.
onStateChange?.call(_lifecycleState!);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<WidgetsBinding>('binding', binding));
properties.add(FlagProperty('onStateChange', value: onStateChange != null, ifTrue: 'onStateChange'));
properties.add(FlagProperty('onInactive', value: onInactive != null, ifTrue: 'onInactive'));
properties.add(FlagProperty('onResume', value: onResume != null, ifTrue: 'onResume'));
properties.add(FlagProperty('onHide', value: onHide != null, ifTrue: 'onHide'));
properties.add(FlagProperty('onShow', value: onShow != null, ifTrue: 'onShow'));
properties.add(FlagProperty('onPause', value: onPause != null, ifTrue: 'onPause'));
properties.add(FlagProperty('onRestart', value: onRestart != null, ifTrue: 'onRestart'));
properties.add(FlagProperty('onExitRequested', value: onExitRequested != null, ifTrue: 'onExitRequested'));
properties.add(FlagProperty('onDetach', value: onDetach != null, ifTrue: 'onDetach'));
}
}
...@@ -3309,14 +3309,10 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget ...@@ -3309,14 +3309,10 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget
update(); update();
case AppLifecycleState.detached: case AppLifecycleState.detached:
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused: case AppLifecycleState.paused:
// Nothing to do. // Nothing to do.
break; break;
// ignore: no_default_cases
default:
// TODO(gspencergoog): Remove this and replace with real cases once
// engine change rolls into framework.
break;
} }
} }
......
...@@ -24,6 +24,7 @@ export 'src/widgets/animated_size.dart'; ...@@ -24,6 +24,7 @@ export 'src/widgets/animated_size.dart';
export 'src/widgets/animated_switcher.dart'; export 'src/widgets/animated_switcher.dart';
export 'src/widgets/annotated_region.dart'; export 'src/widgets/annotated_region.dart';
export 'src/widgets/app.dart'; export 'src/widgets/app.dart';
export 'src/widgets/app_lifecycle_listener.dart';
export 'src/widgets/async.dart'; export 'src/widgets/async.dart';
export 'src/widgets/autocomplete.dart'; export 'src/widgets/autocomplete.dart';
export 'src/widgets/autofill.dart'; export 'src/widgets/autofill.dart';
......
...@@ -9,18 +9,16 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -9,18 +9,16 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
testWidgets('initialLifecycleState is used to init state paused', (WidgetTester tester) async { testWidgets('initialLifecycleState is used to init state paused', (WidgetTester tester) async {
// The lifecycleState is null initially in tests as there is no expect(ServicesBinding.instance.lifecycleState, isNull);
// initialLifecycleState.
expect(ServicesBinding.instance.lifecycleState, equals(null));
// Mock the Window to provide paused as the AppLifecycleState
final TestWidgetsFlutterBinding binding = tester.binding; final TestWidgetsFlutterBinding binding = tester.binding;
binding.resetLifecycleState();
// Use paused as the initial state. // Use paused as the initial state.
binding.platformDispatcher.initialLifecycleStateTestValue = 'AppLifecycleState.paused'; binding.platformDispatcher.initialLifecycleStateTestValue = 'AppLifecycleState.paused';
binding.readTestInitialLifecycleStateFromNativeWindow(); // Re-attempt the initialization. binding.readTestInitialLifecycleStateFromNativeWindow(); // Re-attempt the initialization.
// The lifecycleState should now be the state we passed above, // The lifecycleState should now be the state we passed above,
// even though no lifecycle event was fired from the platform. // even though no lifecycle event was fired from the platform.
expect(ServicesBinding.instance.lifecycleState.toString(), equals('AppLifecycleState.paused')); expect(binding.lifecycleState.toString(), equals('AppLifecycleState.paused'));
}); });
testWidgets('Handles all of the allowed states of AppLifecycleState', (WidgetTester tester) async { testWidgets('Handles all of the allowed states of AppLifecycleState', (WidgetTester tester) async {
final TestWidgetsFlutterBinding binding = tester.binding; final TestWidgetsFlutterBinding binding = tester.binding;
...@@ -31,4 +29,18 @@ void main() { ...@@ -31,4 +29,18 @@ void main() {
expect(ServicesBinding.instance.lifecycleState.toString(), equals(state.toString())); expect(ServicesBinding.instance.lifecycleState.toString(), equals(state.toString()));
} }
}); });
test('AppLifecycleState values are in the right order for the state machine to be correct', () {
expect(
AppLifecycleState.values,
equals(
<AppLifecycleState>[
AppLifecycleState.detached,
AppLifecycleState.resumed,
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
],
),
);
});
} }
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late AppLifecycleListener listener;
Future<void> setAppLifeCycleState(AppLifecycleState state) async {
final ByteData? message = const StringCodec().encodeMessage(state.toString());
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
}
Future<void> sendAppExitRequest() async {
final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('System.requestAppExit'));
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage('flutter/platform', message, (_) {});
}
setUp(() async {
WidgetsFlutterBinding.ensureInitialized();
WidgetsBinding.instance
..resetEpoch()
..platformDispatcher.onBeginFrame = null
..platformDispatcher.onDrawFrame = null;
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance;
binding.readTestInitialLifecycleStateFromNativeWindow();
// Reset the state to detached. Going to paused first makes it a valid
// transition from any state, since the intermediate transitions will be
// generated.
await setAppLifeCycleState(AppLifecycleState.paused);
await setAppLifeCycleState(AppLifecycleState.detached);
});
tearDown(() {
listener.dispose();
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance;
binding.resetLifecycleState();
binding.platformDispatcher.resetInitialLifecycleState();
assert(TestAppLifecycleListener.registerCount == 0,
'There were ${TestAppLifecycleListener.registerCount} listeners that were not disposed of in tests.');
});
testWidgets('Default Diagnostics', (WidgetTester tester) async {
listener = TestAppLifecycleListener(binding: tester.binding);
expect(listener.toString(),
equalsIgnoringHashCodes('TestAppLifecycleListener#00000(binding: <AutomatedTestWidgetsFlutterBinding>)'));
});
testWidgets('Diagnostics', (WidgetTester tester) async {
Future<AppExitResponse> handleExitRequested() async {
return AppExitResponse.cancel;
}
listener = TestAppLifecycleListener(
binding: WidgetsBinding.instance,
onExitRequested: handleExitRequested,
onStateChange: (AppLifecycleState _) {},
);
expect(
listener.toString(),
equalsIgnoringHashCodes(
'TestAppLifecycleListener#00000(binding: <AutomatedTestWidgetsFlutterBinding>, onStateChange, onExitRequested)'));
});
testWidgets('listens to AppLifecycleState', (WidgetTester tester) async {
final List<AppLifecycleState> states = <AppLifecycleState>[tester.binding.lifecycleState!];
void stateChange(AppLifecycleState state) {
states.add(state);
}
listener = TestAppLifecycleListener(
binding: WidgetsBinding.instance,
onStateChange: stateChange,
);
expect(states, equals(<AppLifecycleState>[AppLifecycleState.detached]));
await setAppLifeCycleState(AppLifecycleState.inactive);
// "resumed" is generated.
expect(states,
equals(<AppLifecycleState>[AppLifecycleState.detached, AppLifecycleState.resumed, AppLifecycleState.inactive]));
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(
states,
equals(<AppLifecycleState>[
AppLifecycleState.detached,
AppLifecycleState.resumed,
AppLifecycleState.inactive,
AppLifecycleState.resumed
]));
});
testWidgets('Triggers correct state transition callbacks', (WidgetTester tester) async {
final List<String> transitions = <String>[];
listener = TestAppLifecycleListener(
binding: WidgetsBinding.instance,
onDetach: () => transitions.add('detach'),
onHide: () => transitions.add('hide'),
onInactive: () => transitions.add('inactive'),
onPause: () => transitions.add('pause'),
onRestart: () => transitions.add('restart'),
onResume: () => transitions.add('resume'),
onShow: () => transitions.add('show'),
);
// Try "standard" sequence
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(transitions, equals(<String>['resume']));
await setAppLifeCycleState(AppLifecycleState.inactive);
expect(transitions, equals(<String>['resume', 'inactive']));
await setAppLifeCycleState(AppLifecycleState.hidden);
expect(transitions, equals(<String>['resume', 'inactive', 'hide']));
await setAppLifeCycleState(AppLifecycleState.paused);
expect(transitions, equals(<String>['resume', 'inactive', 'hide', 'pause']));
// Go back to resume
transitions.clear();
await setAppLifeCycleState(AppLifecycleState.hidden);
expect(transitions, equals(<String>['restart']));
await setAppLifeCycleState(AppLifecycleState.inactive);
expect(transitions, equals(<String>['restart', 'show']));
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(transitions, equals(<String>['restart', 'show', 'resume']));
// Generates intermediate states.
transitions.clear();
await setAppLifeCycleState(AppLifecycleState.paused);
expect(transitions, equals(<String>['inactive', 'hide', 'pause']));
// Wraps around from pause to detach.
await setAppLifeCycleState(AppLifecycleState.detached);
expect(transitions, equals(<String>['inactive', 'hide', 'pause', 'detach']));
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(transitions, equals(<String>['inactive', 'hide', 'pause', 'detach', 'resume']));
await setAppLifeCycleState(AppLifecycleState.paused);
expect(transitions, equals(<String>['inactive', 'hide', 'pause', 'detach', 'resume', 'inactive', 'hide', 'pause']));
transitions.clear();
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(transitions, equals(<String>['restart', 'show', 'resume']));
// Asserts on bad transitions
await expectLater(() => setAppLifeCycleState(AppLifecycleState.detached), throwsAssertionError);
await setAppLifeCycleState(AppLifecycleState.paused);
await setAppLifeCycleState(AppLifecycleState.detached);
});
testWidgets('Receives exit requests', (WidgetTester tester) async {
bool exitRequested = false;
Future<AppExitResponse> handleExitRequested() async {
exitRequested = true;
return AppExitResponse.cancel;
}
listener = TestAppLifecycleListener(
binding: WidgetsBinding.instance,
onExitRequested: handleExitRequested,
);
await sendAppExitRequest();
expect(exitRequested, isTrue);
});
}
class TestAppLifecycleListener extends AppLifecycleListener {
TestAppLifecycleListener({
super.binding,
super.onResume,
super.onInactive,
super.onHide,
super.onShow,
super.onPause,
super.onRestart,
super.onDetach,
super.onExitRequested,
super.onStateChange,
}) {
registerCount += 1;
}
static int registerCount = 0;
@override
void dispose() {
super.dispose();
registerCount -= 1;
}
}
...@@ -17,11 +17,11 @@ class MemoryPressureObserver with WidgetsBindingObserver { ...@@ -17,11 +17,11 @@ class MemoryPressureObserver with WidgetsBindingObserver {
} }
class AppLifecycleStateObserver with WidgetsBindingObserver { class AppLifecycleStateObserver with WidgetsBindingObserver {
late AppLifecycleState lifecycleState; List<AppLifecycleState> accumulatedStates = <AppLifecycleState>[];
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
lifecycleState = state; accumulatedStates.add(state);
} }
} }
...@@ -66,19 +66,58 @@ void main() { ...@@ -66,19 +66,58 @@ void main() {
final AppLifecycleStateObserver observer = AppLifecycleStateObserver(); final AppLifecycleStateObserver observer = AppLifecycleStateObserver();
WidgetsBinding.instance.addObserver(observer); WidgetsBinding.instance.addObserver(observer);
setAppLifeCycleState(AppLifecycleState.paused); await setAppLifeCycleState(AppLifecycleState.paused);
expect(observer.lifecycleState, AppLifecycleState.paused); expect(observer.accumulatedStates, <AppLifecycleState>[AppLifecycleState.paused]);
setAppLifeCycleState(AppLifecycleState.resumed); observer.accumulatedStates.clear();
expect(observer.lifecycleState, AppLifecycleState.resumed); await setAppLifeCycleState(AppLifecycleState.resumed);
expect(observer.accumulatedStates, <AppLifecycleState>[
setAppLifeCycleState(AppLifecycleState.inactive); AppLifecycleState.hidden,
expect(observer.lifecycleState, AppLifecycleState.inactive); AppLifecycleState.inactive,
AppLifecycleState.resumed,
setAppLifeCycleState(AppLifecycleState.detached); ]);
expect(observer.lifecycleState, AppLifecycleState.detached);
observer.accumulatedStates.clear();
setAppLifeCycleState(AppLifecycleState.resumed); await setAppLifeCycleState(AppLifecycleState.paused);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
]);
observer.accumulatedStates.clear();
await setAppLifeCycleState(AppLifecycleState.inactive);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.hidden,
AppLifecycleState.inactive,
]);
observer.accumulatedStates.clear();
await setAppLifeCycleState(AppLifecycleState.hidden);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.hidden,
]);
observer.accumulatedStates.clear();
await setAppLifeCycleState(AppLifecycleState.paused);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.paused,
]);
observer.accumulatedStates.clear();
await setAppLifeCycleState(AppLifecycleState.detached);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.detached,
]);
observer.accumulatedStates.clear();
await setAppLifeCycleState(AppLifecycleState.resumed);
expect(observer.accumulatedStates, <AppLifecycleState>[
AppLifecycleState.resumed,
]);
observer.accumulatedStates.clear();
await expectLater(() async => setAppLifeCycleState(AppLifecycleState.detached), throwsAssertionError);
}); });
testWidgets('didPushRoute callback', (WidgetTester tester) async { testWidgets('didPushRoute callback', (WidgetTester tester) async {
...@@ -87,7 +126,7 @@ void main() { ...@@ -87,7 +126,7 @@ void main() {
const String testRouteName = 'testRouteName'; const String testRouteName = 'testRouteName';
final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('pushRoute', testRouteName)); final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('pushRoute', testRouteName));
await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) {});
expect(observer.pushedRoute, testRouteName); expect(observer.pushedRoute, testRouteName);
WidgetsBinding.instance.removeObserver(observer); WidgetsBinding.instance.removeObserver(observer);
...@@ -199,26 +238,29 @@ void main() { ...@@ -199,26 +238,29 @@ void main() {
testWidgets('Application lifecycle affects frame scheduling', (WidgetTester tester) async { testWidgets('Application lifecycle affects frame scheduling', (WidgetTester tester) async {
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.paused); await setAppLifeCycleState(AppLifecycleState.paused);
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.resumed); await setAppLifeCycleState(AppLifecycleState.resumed);
expect(tester.binding.hasScheduledFrame, isTrue); expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump(); await tester.pump();
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.inactive); await setAppLifeCycleState(AppLifecycleState.inactive);
expect(tester.binding.hasScheduledFrame, isFalse);
await setAppLifeCycleState(AppLifecycleState.paused);
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.detached); await setAppLifeCycleState(AppLifecycleState.detached);
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.inactive); await setAppLifeCycleState(AppLifecycleState.inactive);
expect(tester.binding.hasScheduledFrame, isTrue); expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump(); await tester.pump();
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
setAppLifeCycleState(AppLifecycleState.paused); await setAppLifeCycleState(AppLifecycleState.paused);
expect(tester.binding.hasScheduledFrame, isFalse); expect(tester.binding.hasScheduledFrame, isFalse);
tester.binding.scheduleFrame(); tester.binding.scheduleFrame();
...@@ -242,7 +284,7 @@ void main() { ...@@ -242,7 +284,7 @@ void main() {
expect(frameCount, 1); expect(frameCount, 1);
// Get the tester back to a resumed state for subsequent tests. // Get the tester back to a resumed state for subsequent tests.
setAppLifeCycleState(AppLifecycleState.resumed); await setAppLifeCycleState(AppLifecycleState.resumed);
expect(tester.binding.hasScheduledFrame, isTrue); expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump(); await tester.pump();
}); });
......
...@@ -379,7 +379,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -379,7 +379,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
@override @override
void initInstances() { void initInstances() {
// This is intialized here because it's needed for the `super.initInstances` // This is initialized here because it's needed for the `super.initInstances`
// call. It can't be handled as a ctor initializer because it's dependent // call. It can't be handled as a ctor initializer because it's dependent
// on `platformDispatcher`. It can't be handled in the ctor itself because // on `platformDispatcher`. It can't be handled in the ctor itself because
// the base class ctor is called first and calls `initInstances`. // the base class ctor is called first and calls `initInstances`.
...@@ -499,6 +499,17 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -499,6 +499,17 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
}); });
} }
@override
Future<ui.AppExitResponse> exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async {
switch (exitType) {
case ui.AppExitType.cancelable:
// The test framework shouldn't actually exit when requested.
return ui.AppExitResponse.cancel;
case ui.AppExitType.required:
throw FlutterError('Unexpected application exit request while running test');
}
}
/// Re-attempts the initialization of the lifecycle state after providing /// Re-attempts the initialization of the lifecycle state after providing
/// test values in [TestWindow.initialLifecycleStateTestValue]. /// test values in [TestWindow.initialLifecycleStateTestValue].
void readTestInitialLifecycleStateFromNativeWindow() { void readTestInitialLifecycleStateFromNativeWindow() {
...@@ -936,8 +947,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase ...@@ -936,8 +947,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase
try { try {
treeDump = rootElement?.toDiagnosticsNode() ?? DiagnosticsNode.message('<no tree>'); treeDump = rootElement?.toDiagnosticsNode() ?? DiagnosticsNode.message('<no tree>');
// We try to stringify the tree dump here (though we immediately discard the result) because // We try to stringify the tree dump here (though we immediately discard the result) because
// we want to make sure that if it can't be serialised, we replace it with a message that // we want to make sure that if it can't be serialized, we replace it with a message that
// says the tree could not be serialised. Otherwise, the real exception might get obscured // says the tree could not be serialized. Otherwise, the real exception might get obscured
// by side-effects of the underlying issues causing the tree dumping code to flail. // by side-effects of the underlying issues causing the tree dumping code to flail.
treeDump.toStringDeep(); treeDump.toStringDeep();
} catch (exception) { } catch (exception) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment