From 4e940a5e938166a6ab98dd30f6def8957b7625b9 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 3 May 2023 09:26:23 -0700 Subject: [PATCH] Reland: "Determine lifecycle by looking at window focus also" (#41626) This reverts commit 9183bff56244bf8d9700fb1625bb99d8849f31c4 because the Google test failures have been fixed. --- lib/ui/platform_dispatcher.dart | 56 ++++++-- .../io/flutter/app/FlutterActivity.java | 6 + .../flutter/app/FlutterActivityDelegate.java | 5 + .../io/flutter/app/FlutterActivityEvents.java | 6 + .../flutter/app/FlutterFragmentActivity.java | 6 + .../io/flutter/app/FlutterPluginRegistry.java | 15 ++ .../embedding/android/FlutterActivity.java | 8 ++ .../FlutterActivityAndFragmentDelegate.java | 29 +++- .../embedding/android/FlutterFragment.java | 30 ++++ .../FlutterEngineConnectionRegistry.java | 23 +++ .../activity/ActivityPluginBinding.java | 13 ++ .../engine/plugins/shim/ShimRegistrar.java | 17 +++ .../systemchannels/LifecycleChannel.java | 93 ++++++++++-- .../flutter/plugin/common/PluginRegistry.java | 25 ++++ ...lutterActivityAndFragmentDelegateTest.java | 35 ++++- .../systemchannels/LifecycleChannelTest.java | 133 ++++++++++++++++++ 16 files changed, 469 insertions(+), 31 deletions(-) create mode 100644 shell/platform/android/test/io/flutter/embedding/engine/systemchannels/LifecycleChannelTest.java diff --git a/lib/ui/platform_dispatcher.dart b/lib/ui/platform_dispatcher.dart index 6a63871e9ccd6..329a054828156 100644 --- a/lib/ui/platform_dispatcher.dart +++ b/lib/ui/platform_dispatcher.dart @@ -1642,31 +1642,61 @@ class FrameTiming { /// States that an application can be in. /// /// The values below describe notifications from the operating system. -/// Applications should not expect to always receive all possible -/// notifications. For example, if the users pulls out the battery from the -/// device, no notification will be sent before the application is suddenly -/// terminated, along with the rest of the operating system. +/// Applications should not expect to always receive all possible notifications. +/// For example, if the users pulls out the battery from the device, no +/// notification will be sent before the application is suddenly terminated, +/// along with the rest of the operating system. +/// +/// For historical and name collision reasons, Flutter's application state names +/// do not correspond one to one with the state names on all platforms. On +/// Android, for instance, when the OS calls +/// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause()), +/// Flutter will enter the [inactive] state, but when Android calls +/// [`Activity.onStop`](https://developer.android.com/reference/android/app/Activity#onStop()), +/// Flutter enters the [paused] state. See the individual state's documentation +/// for descriptions of what they mean on each platform. /// /// See also: /// -/// * [WidgetsBindingObserver], for a mechanism to observe the lifecycle state -/// from the widgets layer. +/// * [WidgetsBindingObserver], for a mechanism to observe the lifecycle state +/// from the widgets layer. +/// * iOS's [IOKit activity lifecycle](https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle?language=objc) documentation. +/// * Android's [activity lifecycle](https://developer.android.com/guide/components/activities/activity-lifecycle) documentation. +/// * macOS's [AppKit activity lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate?language=objc) documentation. enum AppLifecycleState { - /// The application is visible and responding to user input. + /// The application is visible and responsive to user input. + /// + /// On Android, this state corresponds to the Flutter host view having focus + /// ([`Activity.onWindowFocusChanged`](https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean)) + /// was called with true) while in Android's "resumed" state. It is possible + /// for the Flutter app to be in the [inactive] state while still being in + /// Android's + /// ["onResume"](https://developer.android.com/guide/components/activities/activity-lifecycle) + /// state if the app has lost focus + /// ([`Activity.onWindowFocusChanged`](https://developer.android.com/reference/android/app/Activity#onWindowFocusChanged(boolean)) + /// was called with false), but hasn't had + /// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause()) + /// called on it. resumed, /// The application is in an inactive state and is not receiving user input. /// /// On iOS, this state corresponds to an app or the Flutter host view running - /// in the foreground inactive state. Apps transition to this state when in - /// a phone call, responding to a TouchID request, when entering the app + /// in the foreground inactive state. Apps transition to this state when in a + /// phone call, responding to a TouchID request, when entering the app /// switcher or the control center, or when the UIViewController hosting the /// Flutter app is transitioning. /// - /// On Android, this corresponds to an app or the Flutter host view running - /// in the foreground inactive state. Apps transition to this state when - /// another activity is focused, such as a split-screen app, a phone call, - /// a picture-in-picture app, a system dialog, or another view. + /// On Android, this corresponds to an app or the Flutter host view running in + /// Android's paused state (i.e. + /// [`Activity.onPause`](https://developer.android.com/reference/android/app/Activity#onPause()) + /// has been called), or in Android's "resumed" state (i.e. + /// [`Activity.onResume`](https://developer.android.com/reference/android/app/Activity#onResume()) + /// has been called) but it has lost window focus. Examples of when apps + /// transition to this state include when the app is partially obscured or + /// another activity is focused, such as: a split-screen app, a phone call, a + /// picture-in-picture app, a system dialog, another view, when the + /// notification window shade is down, or the application switcher is visible. /// /// Apps in this state should assume that they may be [paused] at any time. inactive, diff --git a/shell/platform/android/io/flutter/app/FlutterActivity.java b/shell/platform/android/io/flutter/app/FlutterActivity.java index a28176b0f799b..932ad2c5ada57 100644 --- a/shell/platform/android/io/flutter/app/FlutterActivity.java +++ b/shell/platform/android/io/flutter/app/FlutterActivity.java @@ -157,6 +157,12 @@ public void onUserLeaveHint() { eventDelegate.onUserLeaveHint(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + eventDelegate.onWindowFocusChanged(hasFocus); + } + @Override public void onTrimMemory(int level) { eventDelegate.onTrimMemory(level); diff --git a/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java b/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java index 9883d6efdb88f..bcc54b1e9786f 100644 --- a/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java +++ b/shell/platform/android/io/flutter/app/FlutterActivityDelegate.java @@ -260,6 +260,11 @@ public void onUserLeaveHint() { flutterView.getPluginRegistry().onUserLeaveHint(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + flutterView.getPluginRegistry().onWindowFocusChanged(hasFocus); + } + @Override public void onTrimMemory(int level) { // Use a trim level delivered while the application is running so the diff --git a/shell/platform/android/io/flutter/app/FlutterActivityEvents.java b/shell/platform/android/io/flutter/app/FlutterActivityEvents.java index 9340adb405394..abbdec4fe6afa 100644 --- a/shell/platform/android/io/flutter/app/FlutterActivityEvents.java +++ b/shell/platform/android/io/flutter/app/FlutterActivityEvents.java @@ -64,4 +64,10 @@ public interface FlutterActivityEvents /** @see android.app.Activity#onUserLeaveHint() */ void onUserLeaveHint(); + + /** + * @param hasFocus True if the current activity window has focus. + * @see android.app.Activity#onWindowFocusChanged(boolean) + */ + void onWindowFocusChanged(boolean hasFocus); } diff --git a/shell/platform/android/io/flutter/app/FlutterFragmentActivity.java b/shell/platform/android/io/flutter/app/FlutterFragmentActivity.java index bf8935e8bb161..c31a5a20a43a8 100644 --- a/shell/platform/android/io/flutter/app/FlutterFragmentActivity.java +++ b/shell/platform/android/io/flutter/app/FlutterFragmentActivity.java @@ -155,6 +155,12 @@ public void onUserLeaveHint() { eventDelegate.onUserLeaveHint(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + eventDelegate.onWindowFocusChanged(hasFocus); + } + @Override public void onTrimMemory(int level) { super.onTrimMemory(level); diff --git a/shell/platform/android/io/flutter/app/FlutterPluginRegistry.java b/shell/platform/android/io/flutter/app/FlutterPluginRegistry.java index 023e723e93c8e..887a9118979cd 100644 --- a/shell/platform/android/io/flutter/app/FlutterPluginRegistry.java +++ b/shell/platform/android/io/flutter/app/FlutterPluginRegistry.java @@ -28,6 +28,7 @@ public class FlutterPluginRegistry PluginRegistry.RequestPermissionsResultListener, PluginRegistry.ActivityResultListener, PluginRegistry.NewIntentListener, + PluginRegistry.WindowFocusChangedListener, PluginRegistry.UserLeaveHintListener, PluginRegistry.ViewDestroyListener { private static final String TAG = "FlutterPluginRegistry"; @@ -44,6 +45,7 @@ public class FlutterPluginRegistry private final List mActivityResultListeners = new ArrayList<>(0); private final List mNewIntentListeners = new ArrayList<>(0); private final List mUserLeaveHintListeners = new ArrayList<>(0); + private final List mWindowFocusChangedListeners = new ArrayList<>(0); private final List mViewDestroyListeners = new ArrayList<>(0); public FlutterPluginRegistry(FlutterNativeView nativeView, Context context) { @@ -182,6 +184,12 @@ public Registrar addUserLeaveHintListener(UserLeaveHintListener listener) { return this; } + @Override + public Registrar addWindowFocusChangedListener(WindowFocusChangedListener listener) { + mWindowFocusChangedListeners.add(listener); + return this; + } + @Override public Registrar addViewDestroyListener(ViewDestroyListener listener) { mViewDestroyListeners.add(listener); @@ -227,6 +235,13 @@ public void onUserLeaveHint() { } } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + for (WindowFocusChangedListener listener : mWindowFocusChangedListeners) { + listener.onWindowFocusChanged(hasFocus); + } + } + @Override public boolean onViewDestroy(FlutterNativeView view) { boolean handled = false; diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java index ef8b87dbc92b9..53344a1a3ad79 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java @@ -949,6 +949,14 @@ public void onUserLeaveHint() { } } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (stillAttachedForEvent("onWindowFocusChanged")) { + delegate.onWindowFocusChanged(hasFocus); + } + } + @Override public void onTrimMemory(int level) { super.onTrimMemory(level); diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java index 9a9f4d9a6a19c..359eca9514125 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java @@ -581,7 +581,7 @@ public boolean onPreDraw() { void onResume() { Log.v(TAG, "onResume()"); ensureAlive(); - if (host.shouldDispatchAppLifecycleState()) { + if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) { flutterEngine.getLifecycleChannel().appIsResumed(); } } @@ -629,7 +629,7 @@ void updateSystemUiOverlays() { void onPause() { Log.v(TAG, "onPause()"); ensureAlive(); - if (host.shouldDispatchAppLifecycleState()) { + if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) { flutterEngine.getLifecycleChannel().appIsInactive(); } } @@ -652,7 +652,7 @@ void onStop() { Log.v(TAG, "onStop()"); ensureAlive(); - if (host.shouldDispatchAppLifecycleState()) { + if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) { flutterEngine.getLifecycleChannel().appIsPaused(); } @@ -763,7 +763,7 @@ void onDetach() { platformPlugin = null; } - if (host.shouldDispatchAppLifecycleState()) { + if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) { flutterEngine.getLifecycleChannel().appIsDetached(); } @@ -898,6 +898,27 @@ void onUserLeaveHint() { } } + /** + * Invoke this from {@code Activity#onWindowFocusChanged()}. + * + *

A {@code Fragment} host must have its containing {@code Activity} forward this call so that + * the {@code Fragment} can then invoke this method. + */ + void onWindowFocusChanged(boolean hasFocus) { + ensureAlive(); + Log.v(TAG, "Received onWindowFocusChanged: " + (hasFocus ? "true" : "false")); + if (host.shouldDispatchAppLifecycleState() && flutterEngine != null) { + // TODO(gspencergoog): Once we have support for multiple windows/views, + // this code will need to consult the list of windows/views to determine if + // any windows in the app are focused and call the appropriate function. + if (hasFocus) { + flutterEngine.getLifecycleChannel().aWindowIsFocused(); + } else { + flutterEngine.getLifecycleChannel().noWindowsAreFocused(); + } + } + } + /** * Invoke this from {@link android.app.Activity#onTrimMemory(int)}. * diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java b/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java index 0ef5d872358d9..85ce3ade38fb0 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java @@ -8,13 +8,16 @@ import android.content.ComponentCallbacks2; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnWindowFocusChangeListener; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -167,6 +170,19 @@ public class FlutterFragment extends Fragment protected static final String ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED = "should_automatically_handle_on_back_pressed"; + @RequiresApi(18) + private final OnWindowFocusChangeListener onWindowFocusChangeListener = + Build.VERSION.SDK_INT >= 18 + ? new OnWindowFocusChangeListener() { + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (stillAttachedForEvent("onWindowFocusChanged")) { + delegate.onWindowFocusChanged(hasFocus); + } + } + } + : null; + /** * Creates a {@code FlutterFragment} with a default configuration. * @@ -1109,9 +1125,23 @@ public void onStop() { } } + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (Build.VERSION.SDK_INT >= 18) { + view.getViewTreeObserver().addOnWindowFocusChangeListener(onWindowFocusChangeListener); + } + } + @Override public void onDestroyView() { super.onDestroyView(); + if (Build.VERSION.SDK_INT >= 18) { + // onWindowFocusChangeListener is API 18+ only. + requireView() + .getViewTreeObserver() + .removeOnWindowFocusChangeListener(onWindowFocusChangeListener); + } if (stillAttachedForEvent("onDestroyView")) { delegate.onDestroyView(); } diff --git a/shell/platform/android/io/flutter/embedding/engine/FlutterEngineConnectionRegistry.java b/shell/platform/android/io/flutter/embedding/engine/FlutterEngineConnectionRegistry.java index e2bc8b54d9ee4..e1ae7440f02e6 100644 --- a/shell/platform/android/io/flutter/embedding/engine/FlutterEngineConnectionRegistry.java +++ b/shell/platform/android/io/flutter/embedding/engine/FlutterEngineConnectionRegistry.java @@ -732,6 +732,10 @@ private static class FlutterEngineActivityPluginBinding implements ActivityPlugi private final Set onUserLeaveHintListeners = new HashSet<>(); + @NonNull + private final Set + onWindowFocusChangedListeners = new HashSet<>(); + @NonNull private final Set onSaveInstanceStateListeners = new HashSet<>(); @@ -847,6 +851,25 @@ public void removeOnUserLeaveHintListener( onUserLeaveHintListeners.remove(listener); } + @Override + public void addOnWindowFocusChangedListener( + @NonNull io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener) { + onWindowFocusChangedListeners.add(listener); + } + + @Override + public void removeOnWindowFocusChangedListener( + @NonNull io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener) { + onWindowFocusChangedListeners.remove(listener); + } + + void onWindowFocusChanged(boolean hasFocus) { + for (io.flutter.plugin.common.PluginRegistry.WindowFocusChangedListener listener : + onWindowFocusChangedListeners) { + listener.onWindowFocusChanged(hasFocus); + } + } + @Override public void addOnSaveStateListener(@NonNull OnSaveInstanceStateListener listener) { onSaveInstanceStateListeners.add(listener); diff --git a/shell/platform/android/io/flutter/embedding/engine/plugins/activity/ActivityPluginBinding.java b/shell/platform/android/io/flutter/embedding/engine/plugins/activity/ActivityPluginBinding.java index fc2deca721061..8c5f84b453c8a 100644 --- a/shell/platform/android/io/flutter/embedding/engine/plugins/activity/ActivityPluginBinding.java +++ b/shell/platform/android/io/flutter/embedding/engine/plugins/activity/ActivityPluginBinding.java @@ -91,6 +91,19 @@ void removeRequestPermissionsResultListener( */ void removeOnUserLeaveHintListener(@NonNull PluginRegistry.UserLeaveHintListener listener); + /** + * Adds a listener that is invoked whenever the associated {@link android.app.Activity}'s {@code + * onWindowFocusChanged()} method is invoked. + */ + void addOnWindowFocusChangedListener(@NonNull PluginRegistry.WindowFocusChangedListener listener); + + /** + * Removes a listener that was added in {@link + * #addOnWindowFocusChangedListener(PluginRegistry.WindowFocusChangedListener)}. + */ + void removeOnWindowFocusChangedListener( + @NonNull PluginRegistry.WindowFocusChangedListener listener); + /** * Adds a listener that is invoked when the associated {@code Activity} or {@code Fragment} saves * and restores instance state. diff --git a/shell/platform/android/io/flutter/embedding/engine/plugins/shim/ShimRegistrar.java b/shell/platform/android/io/flutter/embedding/engine/plugins/shim/ShimRegistrar.java index 9c40d0a016f21..d27da01dae04b 100644 --- a/shell/platform/android/io/flutter/embedding/engine/plugins/shim/ShimRegistrar.java +++ b/shell/platform/android/io/flutter/embedding/engine/plugins/shim/ShimRegistrar.java @@ -39,6 +39,8 @@ class ShimRegistrar implements PluginRegistry.Registrar, FlutterPlugin, Activity new HashSet<>(); private final Set newIntentListeners = new HashSet<>(); private final Set userLeaveHintListeners = new HashSet<>(); + private final Set WindowFocusChangedListeners = + new HashSet<>(); private FlutterPlugin.FlutterPluginBinding pluginBinding; private ActivityPluginBinding activityPluginBinding; @@ -146,6 +148,18 @@ public PluginRegistry.Registrar addUserLeaveHintListener( return this; } + @Override + public PluginRegistry.Registrar addWindowFocusChangedListener( + PluginRegistry.WindowFocusChangedListener listener) { + WindowFocusChangedListeners.add(listener); + + if (activityPluginBinding != null) { + activityPluginBinding.addOnWindowFocusChangedListener(listener); + } + + return this; + } + @Override @NonNull public PluginRegistry.Registrar addViewDestroyListener( @@ -213,5 +227,8 @@ private void addExistingListenersToActivityPluginBinding() { for (PluginRegistry.UserLeaveHintListener listener : userLeaveHintListeners) { activityPluginBinding.addOnUserLeaveHintListener(listener); } + for (PluginRegistry.WindowFocusChangedListener listener : WindowFocusChangedListeners) { + activityPluginBinding.addOnWindowFocusChangedListener(listener); + } } } diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/LifecycleChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/LifecycleChannel.java index 9e8c22bc909be..f34f774536018 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/LifecycleChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/LifecycleChannel.java @@ -5,39 +5,106 @@ package io.flutter.embedding.engine.systemchannels; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import io.flutter.Log; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.plugin.common.BasicMessageChannel; import io.flutter.plugin.common.StringCodec; -/** TODO(mattcarroll): fill in javadoc for LifecycleChannel. */ +/** + * A {@link BasicMessageChannel} that communicates lifecycle events to the framework. + * + *

The activity listens to the Android lifecycle events, in addition to the focus events for + * windows, and this channel combines that information to decide if the application is the inactive, + * resumed, paused, or detached state. + */ public class LifecycleChannel { private static final String TAG = "LifecycleChannel"; + private static final String CHANNEL_NAME = "flutter/lifecycle"; - @NonNull public final BasicMessageChannel channel; + // These should stay in sync with the AppLifecycleState enum in the framework. + private static final String RESUMED = "AppLifecycleState.resumed"; + private static final String INACTIVE = "AppLifecycleState.inactive"; + private static final String PAUSED = "AppLifecycleState.paused"; + private static final String DETACHED = "AppLifecycleState.detached"; + + private String lastAndroidState = ""; + private String lastFlutterState = ""; + private boolean lastFocus = false; + + @NonNull private final BasicMessageChannel channel; public LifecycleChannel(@NonNull DartExecutor dartExecutor) { - this.channel = - new BasicMessageChannel<>(dartExecutor, "flutter/lifecycle", StringCodec.INSTANCE); + this(new BasicMessageChannel(dartExecutor, CHANNEL_NAME, StringCodec.INSTANCE)); } - public void appIsInactive() { - Log.v(TAG, "Sending AppLifecycleState.inactive message."); - channel.send("AppLifecycleState.inactive"); + @VisibleForTesting + public LifecycleChannel(@NonNull BasicMessageChannel channel) { + this.channel = channel; + } + + // Here's the state table this implements: + // + // | 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 | + private void sendState(String state, boolean hasFocus) { + if (lastAndroidState == state && hasFocus == lastFocus) { + // No inputs changed, so Flutter state could not have changed. + return; + } + String newState; + if (state == RESUMED) { + // Focus is only taken into account when the Android state is "Resumed". + // In all other states, focus is ignored, because we can't know what order + // Android lifecycle notifications and window focus notifications events + // will arrive in, and those states don't send input events anyhow. + newState = hasFocus ? RESUMED : INACTIVE; + } else { + newState = state; + } + // Keep the last reported values for future updates. + lastAndroidState = state; + lastFocus = hasFocus; + if (newState == lastFlutterState) { + // No change in the resulting Flutter state, so don't report anything. + return; + } + Log.v(TAG, "Sending " + newState + " message."); + channel.send(newState); + lastFlutterState = newState; + } + + // Called if at least one window in the app has focus. + public void aWindowIsFocused() { + sendState(lastAndroidState, true); + } + + // Called if no windows in the app have focus. + public void noWindowsAreFocused() { + sendState(lastAndroidState, false); } public void appIsResumed() { - Log.v(TAG, "Sending AppLifecycleState.resumed message."); - channel.send("AppLifecycleState.resumed"); + sendState(RESUMED, lastFocus); + } + + public void appIsInactive() { + sendState(INACTIVE, lastFocus); } public void appIsPaused() { - Log.v(TAG, "Sending AppLifecycleState.paused message."); - channel.send("AppLifecycleState.paused"); + sendState(PAUSED, lastFocus); } public void appIsDetached() { - Log.v(TAG, "Sending AppLifecycleState.detached message."); - channel.send("AppLifecycleState.detached"); + sendState(DETACHED, lastFocus); } } diff --git a/shell/platform/android/io/flutter/plugin/common/PluginRegistry.java b/shell/platform/android/io/flutter/plugin/common/PluginRegistry.java index 649bce820dbec..4859617d93b29 100644 --- a/shell/platform/android/io/flutter/plugin/common/PluginRegistry.java +++ b/shell/platform/android/io/flutter/plugin/common/PluginRegistry.java @@ -309,6 +309,23 @@ Registrar addRequestPermissionsResultListener( @NonNull Registrar addUserLeaveHintListener(@NonNull UserLeaveHintListener listener); + /** + * Adds a callback allowing the plugin to take part in handling incoming calls to {@link + * Activity#onWindowFocusChanged(boolean)}. + * + *

This registrar is for Flutter's v1 embedding. To listen for leave hints in the v2 + * embedding, use {@link + * ActivityPluginBinding#addOnWindowFocusChangedListener(PluginRegistry.WindowFocusChangedListener)}. + * + *

For instructions on migrating a plugin from Flutter's v1 Android embedding to v2, visit + * http://flutter.dev/go/android-plugin-migration + * + * @param listener a {@link WindowFocusChangedListener} callback. + * @return this {@link Registrar}. + */ + @NonNull + Registrar addWindowFocusChangedListener(@NonNull WindowFocusChangedListener listener); + /** * Adds a callback allowing the plugin to take part in handling incoming calls to {@link * Activity#onDestroy()}. @@ -388,6 +405,14 @@ interface UserLeaveHintListener { void onUserLeaveHint(); } + /** + * Delegate interface for handling window focus changes on behalf of the main {@link + * android.app.Activity}. + */ + interface WindowFocusChangedListener { + void onWindowFocusChanged(boolean hasFocus); + } + /** * Delegate interface for handling an {@link android.app.Activity}'s onDestroy method being * called. A plugin that implements this interface can adopt the {@link FlutterNativeView} by diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java index 8c1f545853087..d5daa43f269a2 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java @@ -107,13 +107,36 @@ public void itSendsLifecycleEventsToFlutter() { // By the time an Activity/Fragment is started, we don't expect any lifecycle messages // to have been sent to Flutter. delegate.onStart(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsResumed(); - verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsInactive(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsDetached(); // When the Activity/Fragment is resumed, a resumed message should have been sent to Flutter. delegate.onResume(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).noWindowsAreFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsInactive(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsDetached(); + + // When the app loses focus because something else has it (e.g. notification + // windowshade or app switcher), it should go to inactive. + delegate.onWindowFocusChanged(false); + verify(mockFlutterEngine.getLifecycleChannel(), never()).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).noWindowsAreFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsInactive(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsDetached(); + + // When the app regains focus, it should go to resumed again. + delegate.onWindowFocusChanged(true); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsInactive(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); @@ -121,6 +144,8 @@ public void itSendsLifecycleEventsToFlutter() { // When the Activity/Fragment is paused, an inactive message should have been sent to Flutter. delegate.onPause(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsInactive(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); @@ -130,6 +155,8 @@ public void itSendsLifecycleEventsToFlutter() { // Notice that Flutter uses the term "paused" in a different way, and at a different time // than the Android OS. delegate.onStop(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsInactive(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsPaused(); @@ -137,6 +164,8 @@ public void itSendsLifecycleEventsToFlutter() { // When activity detaches, a detached message should have been sent to Flutter. delegate.onDetach(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), times(1)).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsResumed(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsInactive(); verify(mockFlutterEngine.getLifecycleChannel(), times(1)).appIsPaused(); @@ -157,10 +186,14 @@ public void itDoesNotSendsLifecycleEventsToFlutter() { delegate.onCreateView(null, null, null, 0, true); delegate.onStart(); delegate.onResume(); + delegate.onWindowFocusChanged(false); + delegate.onWindowFocusChanged(true); delegate.onPause(); delegate.onStop(); delegate.onDetach(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).aWindowIsFocused(); + verify(mockFlutterEngine.getLifecycleChannel(), never()).noWindowsAreFocused(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsResumed(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsPaused(); verify(mockFlutterEngine.getLifecycleChannel(), never()).appIsInactive(); diff --git a/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/LifecycleChannelTest.java b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/LifecycleChannelTest.java new file mode 100644 index 0000000000000..e9211d5f3b411 --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/LifecycleChannelTest.java @@ -0,0 +1,133 @@ +package io.flutter.embedding.engine.systemchannels; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.flutter.plugin.common.BasicMessageChannel; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.annotation.Config; + +@Config(manifest = Config.NONE) +@RunWith(AndroidJUnit4.class) +public class LifecycleChannelTest { + LifecycleChannel lifecycleChannel; + BasicMessageChannel mockChannel; + + @Before + public void setUp() { + mockChannel = mock(BasicMessageChannel.class); + lifecycleChannel = new LifecycleChannel(mockChannel); + } + + @Test + public void lifecycleChannel_handlesResumed() { + lifecycleChannel.appIsResumed(); + ArgumentCaptor stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(1)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.inactive", stringArgumentCaptor.getValue()); + + lifecycleChannel.aWindowIsFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(2)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.resumed", stringArgumentCaptor.getValue()); + + lifecycleChannel.noWindowsAreFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(3)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.inactive", stringArgumentCaptor.getValue()); + + // Stays inactive, so no event is sent. + lifecycleChannel.appIsInactive(); + verify(mockChannel, times(3)).send(any(String.class)); + + // Stays inactive, so no event is sent. + lifecycleChannel.appIsResumed(); + verify(mockChannel, times(3)).send(any(String.class)); + + lifecycleChannel.aWindowIsFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(4)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.resumed", stringArgumentCaptor.getValue()); + } + + @Test + public void lifecycleChannel_handlesInactive() { + lifecycleChannel.appIsInactive(); + ArgumentCaptor stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(1)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.inactive", stringArgumentCaptor.getValue()); + + // Stays inactive, so no event is sent. + lifecycleChannel.aWindowIsFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + // Stays inactive, so no event is sent. + lifecycleChannel.noWindowsAreFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + lifecycleChannel.appIsResumed(); + lifecycleChannel.aWindowIsFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(2)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.resumed", stringArgumentCaptor.getValue()); + } + + @Test + public void lifecycleChannel_handlesPaused() { + // Stays inactive, so no event is sent. + lifecycleChannel.appIsPaused(); + ArgumentCaptor stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(1)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.paused", stringArgumentCaptor.getValue()); + + // Stays paused, so no event is sent. + lifecycleChannel.aWindowIsFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + lifecycleChannel.noWindowsAreFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + lifecycleChannel.appIsResumed(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(2)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.inactive", stringArgumentCaptor.getValue()); + + lifecycleChannel.aWindowIsFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(3)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.resumed", stringArgumentCaptor.getValue()); + } + + @Test + public void lifecycleChannel_handlesDetached() { + // Stays inactive, so no event is sent. + lifecycleChannel.appIsDetached(); + ArgumentCaptor stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(1)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.detached", stringArgumentCaptor.getValue()); + + // Stays paused, so no event is sent. + lifecycleChannel.aWindowIsFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + lifecycleChannel.noWindowsAreFocused(); + verify(mockChannel, times(1)).send(any(String.class)); + + lifecycleChannel.appIsResumed(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(2)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.inactive", stringArgumentCaptor.getValue()); + + lifecycleChannel.aWindowIsFocused(); + stringArgumentCaptor = ArgumentCaptor.forClass(String.class); + verify(mockChannel, times(3)).send(stringArgumentCaptor.capture()); + assertEquals("AppLifecycleState.resumed", stringArgumentCaptor.getValue()); + } +}