Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit eb68df6

Browse files
authored
Determine lifecycle by looking at window focus also (#41094)
## Description This incorporates additional signal from `Activity.onWindowFocusChanged` to help decide if the application is `resumed` or `inactive`. When the user pulls down the notification shade or opens the app switcher in iOS, then iOS sends a notification to the application that it no longer has input focus (is no longer "active" in Apple terminology). However, Android (at least on a Pixel) doesn't send `onPause` and `onResume` events for these things, as one might expect. Instead, this PR changes things so that we listen to `Activity.onWindowFocusChanged` and see if any of the windows still have focus. If it doesn't have focus, then the lifecycle switches to `inactive` (even if `onPause` hasn't been called), and if it does have focus (and `onResume` hasn't been called) then we should go to `resumed`. State changes are determined and deduped in the `LifecycleChannel` class. Here's the old state table: | Android State | Flutter state | | ------------- | ------------- | | Resumed | resumed | | Paused | inactive | | Stopped | paused | | Detached | detached | Here's the new state table: | Android State | Window focused | Flutter state | | ------------- | ------------- | ------------- | | Resumed | true | resumed | | Resumed | false | _inactive_ * | | Paused | true | inactive | | Paused | false | inactive | | Stopped | true | paused | | Stopped | false | paused | | Detached | true | detached | | Detached | false | detached | * = This is the relevant change in this PR. ("Window focused" means one or more windows managed by Flutter are focused) The `inactive` state is for when the application is running and visible, but doesn't have the input focus. An example where this currently happens are when a phone call is in progress on top of the app, or on some OEMs when going into the app switcher (I've tested on Realme and it does that, at least). With the PR, it will also go into `inactive` when the app has lost input focus, but is still in the Android `onResume` state. This means that on phones that don't pause the app when they go into the app switcher or the notification window shade (Pixel, others), the app will go into `inactive` when it didn't before. If developers weren't doing anything special in the `inactive` state before, then this PR will have no change for them. If they were, they will go into that state more often (but more consistently across OEMs). ## Related Issues - Fixes flutter/flutter#124591 ## Tests - Added unit tests for handling `onWindowFocusChanged`.
1 parent b883217 commit eb68df6

16 files changed

+469
-31
lines changed

lib/ui/platform_dispatcher.dart

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,31 +1642,61 @@ class FrameTiming {
16421642
/// States that an application can be in.
16431643
///
16441644
/// The values below describe notifications from the operating system.
1645-
/// Applications should not expect to always receive all possible
1646-
/// notifications. For example, if the users pulls out the battery from the
1647-
/// device, no notification will be sent before the application is suddenly
1648-
/// terminated, along with the rest of the operating system.
1645+
/// Applications should not expect to always receive all possible notifications.
1646+
/// For example, if the users pulls out the battery from the device, no
1647+
/// notification will be sent before the application is suddenly terminated,
1648+
/// along with the rest of the operating system.
1649+
///
1650+
/// For historical and name collision reasons, Flutter's application state names
1651+
/// do not correspond one to one with the state names on all platforms. On
1652+
/// Android, for instance, when the OS calls
1653+
/// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause()),
1654+
/// Flutter will enter the [inactive] state, but when Android calls
1655+
/// [`Activity.onStop`](https://developer.android.com/reference/android/app/Activity#onStop()),
1656+
/// Flutter enters the [paused] state. See the individual state's documentation
1657+
/// for descriptions of what they mean on each platform.
16491658
///
16501659
/// See also:
16511660
///
1652-
/// * [WidgetsBindingObserver], for a mechanism to observe the lifecycle state
1653-
/// from the widgets layer.
1661+
/// * [WidgetsBindingObserver], for a mechanism to observe the lifecycle state
1662+
/// from the widgets layer.
1663+
/// * iOS's [IOKit activity lifecycle](https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle?language=objc) documentation.
1664+
/// * Android's [activity lifecycle](https://developer.android.com/guide/components/activities/activity-lifecycle) documentation.
1665+
/// * macOS's [AppKit activity lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate?language=objc) documentation.
16541666
enum AppLifecycleState {
1655-
/// The application is visible and responding to user input.
1667+
/// The application is visible and responsive to user input.
1668+
///
1669+
/// On Android, this state corresponds to the Flutter host view having focus
1670+
/// ([`Activity.onWindowFocusChanged`](https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean))
1671+
/// was called with true) while in Android's "resumed" state. It is possible
1672+
/// for the Flutter app to be in the [inactive] state while still being in
1673+
/// Android's
1674+
/// ["onResume"](https://developer.android.com/guide/components/activities/activity-lifecycle)
1675+
/// state if the app has lost focus
1676+
/// ([`Activity.onWindowFocusChanged`](https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean))
1677+
/// was called with false), but hasn't had
1678+
/// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause())
1679+
/// called on it.
16561680
resumed,
16571681

16581682
/// The application is in an inactive state and is not receiving user input.
16591683
///
16601684
/// On iOS, this state corresponds to an app or the Flutter host view running
1661-
/// in the foreground inactive state. Apps transition to this state when in
1662-
/// a phone call, responding to a TouchID request, when entering the app
1685+
/// in the foreground inactive state. Apps transition to this state when in a
1686+
/// phone call, responding to a TouchID request, when entering the app
16631687
/// switcher or the control center, or when the UIViewController hosting the
16641688
/// Flutter app is transitioning.
16651689
///
1666-
/// On Android, this corresponds to an app or the Flutter host view running
1667-
/// in the foreground inactive state. Apps transition to this state when
1668-
/// another activity is focused, such as a split-screen app, a phone call,
1669-
/// a picture-in-picture app, a system dialog, or another view.
1690+
/// On Android, this corresponds to an app or the Flutter host view running in
1691+
/// Android's paused state (i.e.
1692+
/// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause())
1693+
/// has been called), or in Android's "resumed" state (i.e.
1694+
/// [`Activity.onResume`](https://developer.android.com/reference/android/app/Activity#onResume())
1695+
/// has been called) but it has lost window focus. Examples of when apps
1696+
/// transition to this state include when the app is partially obscured or
1697+
/// another activity is focused, such as: a split-screen app, a phone call, a
1698+
/// picture-in-picture app, a system dialog, another view, when the
1699+
/// notification window shade is down, or the application switcher is visible.
16701700
///
16711701
/// Apps in this state should assume that they may be [paused] at any time.
16721702
inactive,

shell/platform/android/io/flutter/app/FlutterActivity.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ public void onUserLeaveHint() {
157157
eventDelegate.onUserLeaveHint();
158158
}
159159

160+
@Override
161+
public void onWindowFocusChanged(boolean hasFocus) {
162+
super.onWindowFocusChanged(hasFocus);
163+
eventDelegate.onWindowFocusChanged(hasFocus);
164+
}
165+
160166
@Override
161167
public void onTrimMemory(int level) {
162168
eventDelegate.onTrimMemory(level);

shell/platform/android/io/flutter/app/FlutterActivityDelegate.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,11 @@ public void onUserLeaveHint() {
260260
flutterView.getPluginRegistry().onUserLeaveHint();
261261
}
262262

263+
@Override
264+
public void onWindowFocusChanged(boolean hasFocus) {
265+
flutterView.getPluginRegistry().onWindowFocusChanged(hasFocus);
266+
}
267+
263268
@Override
264269
public void onTrimMemory(int level) {
265270
// Use a trim level delivered while the application is running so the

shell/platform/android/io/flutter/app/FlutterActivityEvents.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,10 @@ public interface FlutterActivityEvents
6464

6565
/** @see android.app.Activity#onUserLeaveHint() */
6666
void onUserLeaveHint();
67+
68+
/**
69+
* @param hasFocus True if the current activity window has focus.
70+
* @see android.app.Activity#onWindowFocusChanged(boolean)
71+
*/
72+
void onWindowFocusChanged(boolean hasFocus);
6773
}

shell/platform/android/io/flutter/app/FlutterFragmentActivity.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ public void onUserLeaveHint() {
155155
eventDelegate.onUserLeaveHint();
156156
}
157157

158+
@Override
159+
public void onWindowFocusChanged(boolean hasFocus) {
160+
super.onWindowFocusChanged(hasFocus);
161+
eventDelegate.onWindowFocusChanged(hasFocus);
162+
}
163+
158164
@Override
159165
public void onTrimMemory(int level) {
160166
super.onTrimMemory(level);

shell/platform/android/io/flutter/app/FlutterPluginRegistry.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class FlutterPluginRegistry
2828
PluginRegistry.RequestPermissionsResultListener,
2929
PluginRegistry.ActivityResultListener,
3030
PluginRegistry.NewIntentListener,
31+
PluginRegistry.WindowFocusChangedListener,
3132
PluginRegistry.UserLeaveHintListener,
3233
PluginRegistry.ViewDestroyListener {
3334
private static final String TAG = "FlutterPluginRegistry";
@@ -44,6 +45,7 @@ public class FlutterPluginRegistry
4445
private final List<ActivityResultListener> mActivityResultListeners = new ArrayList<>(0);
4546
private final List<NewIntentListener> mNewIntentListeners = new ArrayList<>(0);
4647
private final List<UserLeaveHintListener> mUserLeaveHintListeners = new ArrayList<>(0);
48+
private final List<WindowFocusChangedListener> mWindowFocusChangedListeners = new ArrayList<>(0);
4749
private final List<ViewDestroyListener> mViewDestroyListeners = new ArrayList<>(0);
4850

4951
public FlutterPluginRegistry(FlutterNativeView nativeView, Context context) {
@@ -182,6 +184,12 @@ public Registrar addUserLeaveHintListener(UserLeaveHintListener listener) {
182184
return this;
183185
}
184186

187+
@Override
188+
public Registrar addWindowFocusChangedListener(WindowFocusChangedListener listener) {
189+
mWindowFocusChangedListeners.add(listener);
190+
return this;
191+
}
192+
185193
@Override
186194
public Registrar addViewDestroyListener(ViewDestroyListener listener) {
187195
mViewDestroyListeners.add(listener);
@@ -227,6 +235,13 @@ public void onUserLeaveHint() {
227235
}
228236
}
229237

238+
@Override
239+
public void onWindowFocusChanged(boolean hasFocus) {
240+
for (WindowFocusChangedListener listener : mWindowFocusChangedListeners) {
241+
listener.onWindowFocusChanged(hasFocus);
242+
}
243+
}
244+
230245
@Override
231246
public boolean onViewDestroy(FlutterNativeView view) {
232247
boolean handled = false;

shell/platform/android/io/flutter/embedding/android/FlutterActivity.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,14 @@ public void onUserLeaveHint() {
949949
}
950950
}
951951

952+
@Override
953+
public void onWindowFocusChanged(boolean hasFocus) {
954+
super.onWindowFocusChanged(hasFocus);
955+
if (stillAttachedForEvent("onWindowFocusChanged")) {
956+
delegate.onWindowFocusChanged(hasFocus);
957+
}
958+
}
959+
952960
@Override
953961
public void onTrimMemory(int level) {
954962
super.onTrimMemory(level);

shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ public boolean onPreDraw() {
581581
void onResume() {
582582
Log.v(TAG, "onResume()");
583583
ensureAlive();
584-
if (host.shouldDispatchAppLifecycleState()) {
584+
if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) {
585585
flutterEngine.getLifecycleChannel().appIsResumed();
586586
}
587587
}
@@ -629,7 +629,7 @@ void updateSystemUiOverlays() {
629629
void onPause() {
630630
Log.v(TAG, "onPause()");
631631
ensureAlive();
632-
if (host.shouldDispatchAppLifecycleState()) {
632+
if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) {
633633
flutterEngine.getLifecycleChannel().appIsInactive();
634634
}
635635
}
@@ -652,7 +652,7 @@ void onStop() {
652652
Log.v(TAG, "onStop()");
653653
ensureAlive();
654654

655-
if (host.shouldDispatchAppLifecycleState()) {
655+
if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) {
656656
flutterEngine.getLifecycleChannel().appIsPaused();
657657
}
658658

@@ -763,7 +763,7 @@ void onDetach() {
763763
platformPlugin = null;
764764
}
765765

766-
if (host.shouldDispatchAppLifecycleState()) {
766+
if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) {
767767
flutterEngine.getLifecycleChannel().appIsDetached();
768768
}
769769

@@ -898,6 +898,27 @@ void onUserLeaveHint() {
898898
}
899899
}
900900

901+
/**
902+
* Invoke this from {@code Activity#onWindowFocusChanged()}.
903+
*
904+
* <p>A {@code Fragment} host must have its containing {@code Activity} forward this call so that
905+
* the {@code Fragment} can then invoke this method.
906+
*/
907+
void onWindowFocusChanged(boolean hasFocus) {
908+
ensureAlive();
909+
Log.v(TAG, "Received onWindowFocusChanged: " + (hasFocus ? "true" : "false"));
910+
if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) {
911+
// TODO(gspencergoog): Once we have support for multiple windows/views,
912+
// this code will need to consult the list of windows/views to determine if
913+
// any windows in the app are focused and call the appropriate function.
914+
if (hasFocus) {
915+
flutterEngine.getLifecycleChannel().aWindowIsFocused();
916+
} else {
917+
flutterEngine.getLifecycleChannel().noWindowsAreFocused();
918+
}
919+
}
920+
}
921+
901922
/**
902923
* Invoke this from {@link android.app.Activity#onTrimMemory(int)}.
903924
*

shell/platform/android/io/flutter/embedding/android/FlutterFragment.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
import android.content.ComponentCallbacks2;
99
import android.content.Context;
1010
import android.content.Intent;
11+
import android.os.Build;
1112
import android.os.Bundle;
1213
import android.view.LayoutInflater;
1314
import android.view.View;
1415
import android.view.ViewGroup;
16+
import android.view.ViewTreeObserver.OnWindowFocusChangeListener;
1517
import androidx.activity.OnBackPressedCallback;
1618
import androidx.annotation.NonNull;
1719
import androidx.annotation.Nullable;
20+
import androidx.annotation.RequiresApi;
1821
import androidx.annotation.VisibleForTesting;
1922
import androidx.fragment.app.Fragment;
2023
import androidx.fragment.app.FragmentActivity;
@@ -167,6 +170,19 @@ public class FlutterFragment extends Fragment
167170
protected static final String ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED =
168171
"should_automatically_handle_on_back_pressed";
169172

173+
@RequiresApi(18)
174+
private final OnWindowFocusChangeListener onWindowFocusChangeListener =
175+
Build.VERSION.SDK_INT >= 18
176+
? new OnWindowFocusChangeListener() {
177+
@Override
178+
public void onWindowFocusChanged(boolean hasFocus) {
179+
if (stillAttachedForEvent("onWindowFocusChanged")) {
180+
delegate.onWindowFocusChanged(hasFocus);
181+
}
182+
}
183+
}
184+
: null;
185+
170186
/**
171187
* Creates a {@code FlutterFragment} with a default configuration.
172188
*
@@ -1109,9 +1125,23 @@ public void onStop() {
11091125
}
11101126
}
11111127

1128+
@Override
1129+
public void onViewCreated(View view, Bundle savedInstanceState) {
1130+
super.onViewCreated(view, savedInstanceState);
1131+
if (Build.VERSION.SDK_INT >= 18) {
1132+
view.getViewTreeObserver().addOnWindowFocusChangeListener(onWindowFocusChangeListener);
1133+
}
1134+
}
1135+
11121136
@Override
11131137
public void onDestroyView() {
11141138
super.onDestroyView();
1139+
if (Build.VERSION.SDK_INT >= 18) {
1140+
// onWindowFocusChangeListener is API 18+ only.
1141+
requireView()
1142+
.getViewTreeObserver()
1143+
.removeOnWindowFocusChangeListener(onWindowFocusChangeListener);
1144+
}
11151145
if (stillAttachedForEvent("onDestroyView")) {
11161146
delegate.onDestroyView();
11171147
}

shell/platform/android/io/flutter/embedding/engine/FlutterEngineConnectionRegistry.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,10 @@ private static class FlutterEngineActivityPluginBinding implements ActivityPlugi
732732
private final Set<io.flutter.plugin.common.PluginRegistry.UserLeaveHintListener>
733733
onUserLeaveHintListeners = new HashSet<>();
734734

735+
@NonNull
736+
private final Set<io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener>
737+
onWindowFocusChangedListeners = new HashSet<>();
738+
735739
@NonNull
736740
private final Set<OnSaveInstanceStateListener> onSaveInstanceStateListeners = new HashSet<>();
737741

@@ -847,6 +851,25 @@ public void removeOnUserLeaveHintListener(
847851
onUserLeaveHintListeners.remove(listener);
848852
}
849853

854+
@Override
855+
public void addOnWindowFocusChangedListener(
856+
@NonNull io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener) {
857+
onWindowFocusChangedListeners.add(listener);
858+
}
859+
860+
@Override
861+
public void removeOnWindowFocusChangedListener(
862+
@NonNull io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener) {
863+
onWindowFocusChangedListeners.remove(listener);
864+
}
865+
866+
void onWindowFocusChanged(boolean hasFocus) {
867+
for (io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener :
868+
onWindowFocusChangedListeners) {
869+
listener.onWindowFocusChanged(hasFocus);
870+
}
871+
}
872+
850873
@Override
851874
public void addOnSaveStateListener(@NonNull OnSaveInstanceStateListener listener) {
852875
onSaveInstanceStateListeners.add(listener);

0 commit comments

Comments
 (0)