From ef4fd403dc43a1f4ac07f560ff14ac435b9f1cf8 Mon Sep 17 00:00:00 2001 From: moko256 Date: Sun, 18 Jan 2026 23:32:32 +0900 Subject: [PATCH] [Android] Add predictive back support for FlutterFragment and FlutterFragmentActivity --- .../embedding/android/FlutterFragment.java | 66 +++++++++++++++- .../android/FlutterFragmentTest.java | 75 ++++++++++++++++++- 2 files changed, 137 insertions(+), 4 deletions(-) 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 e1ff3f99f9a54..515c40475ef9e 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 @@ -4,18 +4,23 @@ package io.flutter.embedding.android; +import static io.flutter.Build.API_LEVELS; + import android.app.Activity; 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.BackEventCompat; 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; @@ -1011,13 +1016,40 @@ public FlutterActivityAndFragmentDelegate createDelegate( } @VisibleForTesting - final OnBackPressedCallback onBackPressedCallback = - new OnBackPressedCallback(true) { + final OnBackPressedCallback onBackPressedCallback = createOnBackPressedCallback(); + + private OnBackPressedCallback createOnBackPressedCallback() { + if (Build.VERSION.SDK_INT >= API_LEVELS.API_34) { + return new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - onBackPressed(); + commitBackGesture(); + } + + @Override + public void handleOnBackCancelled() { + cancelBackGesture(); + } + + @Override + public void handleOnBackProgressed(@NonNull BackEventCompat backEvent) { + updateBackGestureProgress(backEvent); + } + + @Override + public void handleOnBackStarted(@NonNull BackEventCompat backEvent) { + startBackGesture(backEvent); } }; + } + + return 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 @@ -1240,6 +1272,34 @@ public void onBackPressed() { } } + @RequiresApi(API_LEVELS.API_34) + public void startBackGesture(@NonNull BackEventCompat backEvent) { + if (stillAttachedForEvent("startBackGesture")) { + delegate.startBackGesture(backEvent.toBackEvent()); + } + } + + @RequiresApi(API_LEVELS.API_34) + public void updateBackGestureProgress(@NonNull BackEventCompat backEvent) { + if (stillAttachedForEvent("updateBackGestureProgress")) { + delegate.updateBackGestureProgress(backEvent.toBackEvent()); + } + } + + @RequiresApi(API_LEVELS.API_34) + public void commitBackGesture() { + if (stillAttachedForEvent("commitBackGesture")) { + delegate.commitBackGesture(); + } + } + + @RequiresApi(API_LEVELS.API_34) + public void cancelBackGesture() { + if (stillAttachedForEvent("cancelBackGesture")) { + delegate.cancelBackGesture(); + } + } + /** * A result has been returned after an invocation of {@link * Fragment#startActivityForResult(Intent, int)}. 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 b60408432ff64..581154ed66e98 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 @@ -19,12 +19,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.annotation.TargetApi; import android.content.Context; +import androidx.activity.BackEventCompat; import androidx.activity.OnBackPressedCallback; +import androidx.activity.OnBackPressedDispatcher; import androidx.fragment.app.FragmentActivity; import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import io.flutter.Build; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.FlutterEngineCache; import io.flutter.embedding.engine.FlutterJNI; @@ -34,6 +38,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; @RunWith(AndroidJUnit4.class) public class FlutterFragmentTest { @@ -286,7 +291,9 @@ public void itReturnsExclusiveAppComponent() { } @Test - public void itDelegatesOnBackPressedWithSetFrameworkHandlesBack() { + @Config(sdk = Build.API_LEVELS.API_33) + @TargetApi(Build.API_LEVELS.API_33) + public void itDelegatesOnBackPressedWithSetFrameworkHandlesBackForSdk33() { // We need to mock FlutterJNI to avoid triggering native code. FlutterJNI flutterJNI = mock(FlutterJNI.class); when(flutterJNI.isAttached()).thenReturn(true); @@ -334,6 +341,72 @@ public void itDelegatesOnBackPressedWithSetFrameworkHandlesBack() { } } + @Test + @Config(sdk = Build.API_LEVELS.API_34) + @TargetApi(Build.API_LEVELS.API_34) + public void itDelegatesOnBackPressedWithSetFrameworkHandlesBackForSdk34OrHigher() { + // We need to mock FlutterJNI to avoid triggering native code. + FlutterJNI flutterJNI = mock(FlutterJNI.class); + when(flutterJNI.isAttached()).thenReturn(true); + + FlutterEngine flutterEngine = + new FlutterEngine(ctx, new FlutterLoader(), flutterJNI, null, false); + FlutterEngineCache.getInstance().put("my_cached_engine", flutterEngine); + + FlutterFragment fragment = + FlutterFragment.withCachedEngine("my_cached_engine") + // This enables the use of onBackPressedCallback, which is what + // sends backs to the framework if setFrameworkHandlesBack is true. + .shouldAutomaticallyHandleOnBackPressed(true) + .build(); + + try (ActivityScenario scenario = + ActivityScenario.launch(FragmentActivity.class)) { + scenario.onActivity( + activity -> { + activity + .getSupportFragmentManager() + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow(); + + FlutterActivityAndFragmentDelegate mockDelegate = + mock(FlutterActivityAndFragmentDelegate.class); + isDelegateAttached = true; + when(mockDelegate.isAttached()).thenAnswer(invocation -> isDelegateAttached); + doAnswer(invocation -> isDelegateAttached = false).when(mockDelegate).onDetach(); + TestDelegateFactory delegateFactory = new TestDelegateFactory(mockDelegate); + fragment.setDelegateFactory(delegateFactory); + + BackEventCompat mockBackEvent = mock(BackEventCompat.class); + OnBackPressedDispatcher dispatcher = activity.getOnBackPressedDispatcher(); + + // Back gesture events now will still be handled by Android (the default), + // until setFrameworkHandlesBack is set to true. + dispatcher.dispatchOnBackStarted(mockBackEvent); + dispatcher.dispatchOnBackProgressed(mockBackEvent); + dispatcher.onBackPressed(); + dispatcher.dispatchOnBackCancelled(); + verify(mockDelegate, times(0)).startBackGesture(any()); + verify(mockDelegate, times(0)).updateBackGestureProgress(any()); + verify(mockDelegate, times(0)).commitBackGesture(); + verify(mockDelegate, times(0)).cancelBackGesture(); + + // Setting setFrameworkHandlesBack to true means the delegate will receive + // the back and Android won't handle it. + fragment.setFrameworkHandlesBack(true); + dispatcher.dispatchOnBackStarted(mockBackEvent); + dispatcher.dispatchOnBackProgressed(mockBackEvent); + dispatcher.onBackPressed(); + dispatcher.dispatchOnBackCancelled(); + verify(mockDelegate, times(1)).startBackGesture(any()); + verify(mockDelegate, times(1)).updateBackGestureProgress(any()); + verify(mockDelegate, times(1)).commitBackGesture(); + verify(mockDelegate, times(1)).cancelBackGesture(); + }); + } + } + @Test public void itHandlesPopSystemNavigationAutomaticallyWhenEnabled() { // We need to mock FlutterJNI to avoid triggering native code.