diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java index 91d673079ac..c1cf09cd8da 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java @@ -12,6 +12,7 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -38,8 +39,11 @@ import io.flutter.plugin.platform.PlatformPlugin; *
  • {@link #onUserLeaveHint()} * * - * Additionally, when starting an {@code Activity} for a result from this {@code Fragment}, be sure - * to invoke {@link Fragment#startActivityForResult(Intent, int)} rather than {@link + * {@link #onBackPressed()} does not need to be called through if the fragment is constructed by one + * of the builders with {@code shouldAutomaticallyHandleOnBackPressed(true)}. + * + *

    Additionally, when starting an {@code Activity} for a result from this {@code Fragment}, be + * sure to invoke {@link Fragment#startActivityForResult(Intent, int)} rather than {@link * android.app.Activity#startActivityForResult(Intent, int)}. If the {@code Activity} version of the * method is invoked then this {@code Fragment} will never receive its {@link * Fragment#onActivityResult(int, int, Intent)} callback. @@ -120,6 +124,12 @@ public class FlutterFragment extends Fragment * when this fragment is created and destroyed. */ protected static final String ARG_ENABLE_STATE_RESTORATION = "enable_state_restoration"; + /** + * True if the fragment should receive {@link #onBackPressed()} events automatically, without + * requiring an explicit activity call through. + */ + protected static final String ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED = + "should_automatically_handle_on_back_pressed"; /** * Creates a {@code FlutterFragment} with a default configuration. @@ -194,6 +204,7 @@ public class FlutterFragment extends Fragment private RenderMode renderMode = RenderMode.surface; private TransparencyMode transparencyMode = TransparencyMode.transparent; private boolean shouldAttachEngineToActivity = true; + private boolean shouldAutomaticallyHandleOnBackPressed = false; /** * Constructs a {@code NewEngineFragmentBuilder} that is configured to construct an instance of @@ -320,6 +331,28 @@ public class FlutterFragment extends Fragment return this; } + /** + * Whether or not this {@code FlutterFragment} should automatically receive {@link + * #onBackPressed()} events, rather than requiring an explicit activity call through. Disabled + * by default. + * + *

    When enabled, the activity will automatically dispatch back-press events to the fragment's + * {@link OnBackPressedCallback}, instead of requiring the activity to manually call {@link + * #onBackPressed()} in client code. If enabled, do not invoke {@link #onBackPressed()} + * manually. + * + *

    This behavior relies on the implementation of {@link #popSystemNavigator()}. It's not + * recommended to override that method when enabling this attribute, but if you do, you should + * always fall back to calling {@code super.popSystemNavigator()} when not relying on custom + * behavior. + */ + @NonNull + public NewEngineFragmentBuilder shouldAutomaticallyHandleOnBackPressed( + boolean shouldAutomaticallyHandleOnBackPressed) { + this.shouldAutomaticallyHandleOnBackPressed = shouldAutomaticallyHandleOnBackPressed; + return this; + } + /** * Creates a {@link Bundle} of arguments that are assigned to the new {@code FlutterFragment}. * @@ -346,6 +379,8 @@ public class FlutterFragment extends Fragment transparencyMode != null ? transparencyMode.name() : TransparencyMode.transparent.name()); args.putBoolean(ARG_SHOULD_ATTACH_ENGINE_TO_ACTIVITY, shouldAttachEngineToActivity); args.putBoolean(ARG_DESTROY_ENGINE_WITH_FRAGMENT, true); + args.putBoolean( + ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, shouldAutomaticallyHandleOnBackPressed); return args; } @@ -428,6 +463,7 @@ public class FlutterFragment extends Fragment private RenderMode renderMode = RenderMode.surface; private TransparencyMode transparencyMode = TransparencyMode.transparent; private boolean shouldAttachEngineToActivity = true; + private boolean shouldAutomaticallyHandleOnBackPressed = false; private CachedEngineFragmentBuilder(@NonNull String engineId) { this(FlutterFragment.class, engineId); @@ -527,6 +563,28 @@ public class FlutterFragment extends Fragment return this; } + /** + * Whether or not this {@code FlutterFragment} should automatically receive {@link + * #onBackPressed()} events, rather than requiring an explicit activity call through. Disabled + * by default. + * + *

    When enabled, the activity will automatically dispatch back-press events to the fragment's + * {@link OnBackPressedCallback}, instead of requiring the activity to manually call {@link + * #onBackPressed()} in client code. If enabled, do not invoke {@link #onBackPressed()} + * manually. + * + *

    Enabling this behavior relies on explicit behavior in {@link #popSystemNavigator()}. It's + * not recommended to override that method when enabling this attribute, but if you do, you + * should always fall back to calling {@code super.popSystemNavigator()} when not relying on + * custom behavior. + */ + @NonNull + public CachedEngineFragmentBuilder shouldAutomaticallyHandleOnBackPressed( + boolean shouldAutomaticallyHandleOnBackPressed) { + this.shouldAutomaticallyHandleOnBackPressed = shouldAutomaticallyHandleOnBackPressed; + return this; + } + /** * Creates a {@link Bundle} of arguments that are assigned to the new {@code FlutterFragment}. * @@ -546,6 +604,8 @@ public class FlutterFragment extends Fragment ARG_FLUTTERVIEW_TRANSPARENCY_MODE, transparencyMode != null ? transparencyMode.name() : TransparencyMode.transparent.name()); args.putBoolean(ARG_SHOULD_ATTACH_ENGINE_TO_ACTIVITY, shouldAttachEngineToActivity); + args.putBoolean( + ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, shouldAutomaticallyHandleOnBackPressed); return args; } @@ -581,6 +641,14 @@ public class FlutterFragment extends Fragment // implementation for details about why it exists. @VisibleForTesting /* package */ FlutterActivityAndFragmentDelegate delegate; + private final OnBackPressedCallback onBackPressedCallback = + new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + onBackPressed(); + } + }; + public FlutterFragment() { // Ensure that we at least have an empty Bundle of arguments so that we don't // need to continually check for null arguments before grabbing one. @@ -607,6 +675,9 @@ public class FlutterFragment extends Fragment super.onAttach(context); delegate = new FlutterActivityAndFragmentDelegate(this); delegate.onAttach(context); + if (getArguments().getBoolean(ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, false)) { + requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); + } } @Override @@ -744,6 +815,9 @@ public class FlutterFragment extends Fragment /** * The hardware back button was pressed. * + *

    If the fragment uses {@code shouldAutomaticallyHandleOnBackPressed(true)}, this method + * should not be called through. It will be called automatically instead. + * *

    See {@link android.app.Activity#onBackPressed()} */ @ActivityCallThrough @@ -1117,8 +1191,28 @@ public class FlutterFragment extends Fragment return true; } + /** + * {@inheritDoc} + * + *

    Avoid overriding this method when using {@code + * shouldAutomaticallyHandleOnBackPressed(true)}. If you do, you must always {@code return + * super.popSystemNavigator()} rather than {@code return false}. Otherwise the navigation behavior + * will recurse infinitely between this method and {@link #onBackPressed()}, breaking navigation. + */ @Override public boolean popSystemNavigator() { + if (getArguments().getBoolean(ARG_SHOULD_AUTOMATICALLY_HANDLE_ON_BACK_PRESSED, false)) { + FragmentActivity activity = getActivity(); + if (activity != null) { + // Unless we disable the callback, the dispatcher call will trigger it. This will then + // trigger the fragment's onBackPressed() implementation, which will call through to the + // dart side and likely call back through to this method, creating an infinite call loop. + onBackPressedCallback.setEnabled(false); + activity.getOnBackPressedDispatcher().onBackPressed(); + onBackPressedCallback.setEnabled(true); + return true; + } + } // Hook for subclass. No-op if returns false. return false; } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java index 8115acd4698..d9ff237c50d 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java @@ -9,10 +9,19 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import androidx.activity.OnBackPressedCallback; +import androidx.fragment.app.FragmentActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterEngineCache; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.loader.FlutterLoader; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; @Config(manifest = Config.NONE) @@ -114,4 +123,70 @@ public class FlutterFragmentTest { verify(mockDelegate, times(1)).onDestroyView(); verify(mockDelegate, times(1)).onDetach(); } + + @Test + public void itDelegatesOnBackPressedAutomaticallyWhenEnabled() { + // We need to mock FlutterJNI to avoid triggering native code. + FlutterJNI flutterJNI = mock(FlutterJNI.class); + when(flutterJNI.isAttached()).thenReturn(true); + + FlutterEngine flutterEngine = + new FlutterEngine( + RuntimeEnvironment.application, new FlutterLoader(), flutterJNI, null, false); + FlutterEngineCache.getInstance().put("my_cached_engine", flutterEngine); + + FlutterFragment fragment = + FlutterFragment.withCachedEngine("my_cached_engine") + .shouldAutomaticallyHandleOnBackPressed(true) + .build(); + FragmentActivity activity = Robolectric.setupActivity(FragmentActivity.class); + activity + .getSupportFragmentManager() + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow(); + + FlutterActivityAndFragmentDelegate mockDelegate = + mock(FlutterActivityAndFragmentDelegate.class); + fragment.setDelegate(mockDelegate); + + activity.onBackPressed(); + + verify(mockDelegate, times(1)).onBackPressed(); + } + + @Test + public void itHandlesPopSystemNavigationAutomaticallyWhenEnabled() { + // We need to mock FlutterJNI to avoid triggering native code. + FlutterJNI flutterJNI = mock(FlutterJNI.class); + when(flutterJNI.isAttached()).thenReturn(true); + + FlutterEngine flutterEngine = + new FlutterEngine( + RuntimeEnvironment.application, new FlutterLoader(), flutterJNI, null, false); + FlutterEngineCache.getInstance().put("my_cached_engine", flutterEngine); + + FlutterFragment fragment = + FlutterFragment.withCachedEngine("my_cached_engine") + .shouldAutomaticallyHandleOnBackPressed(true) + .build(); + FragmentActivity activity = Robolectric.setupActivity(FragmentActivity.class); + activity + .getSupportFragmentManager() + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow(); + OnBackPressedCallback callback = mock(OnBackPressedCallback.class); + when(callback.isEnabled()).thenReturn(true); + activity.getOnBackPressedDispatcher().addCallback(callback); + + FlutterActivityAndFragmentDelegate mockDelegate = + mock(FlutterActivityAndFragmentDelegate.class); + fragment.setDelegate(mockDelegate); + + assertTrue(fragment.popSystemNavigator()); + + verify(mockDelegate, never()).onBackPressed(); + verify(callback, times(1)).handleOnBackPressed(); + } }