diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java index 66c2acd8e886b..95e9b978892a5 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivity.java @@ -13,6 +13,7 @@ import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_DESTROY_ENGINE_WITH_ACTIVITY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_ENABLE_STATE_RESTORATION; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_INITIAL_ROUTE; +import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.HANDLE_DEEPLINKING_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.INITIAL_ROUTE_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.NORMAL_THEME_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.SPLASH_SCREEN_META_DATA_KEY; @@ -446,10 +447,9 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { */ private void switchLaunchThemeForNormalTheme() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - if (activityInfo.metaData != null) { - int normalThemeRID = activityInfo.metaData.getInt(NORMAL_THEME_META_DATA_KEY, -1); + Bundle metaData = getMetaData(); + if (metaData != null) { + int normalThemeRID = metaData.getInt(NORMAL_THEME_META_DATA_KEY, -1); if (normalThemeRID != -1) { setTheme(normalThemeRID); } @@ -485,10 +485,8 @@ public SplashScreen provideSplashScreen() { @SuppressWarnings("deprecation") private Drawable getSplashScreenFromManifest() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; - int splashScreenId = metadata != null ? metadata.getInt(SPLASH_SCREEN_META_DATA_KEY) : 0; + Bundle metaData = getMetaData(); + int splashScreenId = metaData != null ? metaData.getInt(SPLASH_SCREEN_META_DATA_KEY) : 0; return splashScreenId != 0 ? Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP ? getResources().getDrawable(splashScreenId, getTheme()) @@ -748,11 +746,9 @@ public boolean shouldDestroyEngineWithHost() { @NonNull public String getDartEntrypointFunctionName() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; + Bundle metaData = getMetaData(); String desiredDartEntrypoint = - metadata != null ? metadata.getString(DART_ENTRYPOINT_META_DATA_KEY) : null; + metaData != null ? metaData.getString(DART_ENTRYPOINT_META_DATA_KEY) : null; return desiredDartEntrypoint != null ? desiredDartEntrypoint : DEFAULT_DART_ENTRYPOINT; } catch (PackageManager.NameNotFoundException e) { return DEFAULT_DART_ENTRYPOINT; @@ -779,22 +775,22 @@ public String getDartEntrypointFunctionName() { * have control over the incoming {@code Intent}. * *

Subclasses may override this method to directly control the initial route. + * + *

If this method returns null and the {@code shouldHandleDeeplinking} returns true, the + * initial route is derived from the {@code Intent} through the Intent.getData() instead. */ - @NonNull public String getInitialRoute() { if (getIntent().hasExtra(EXTRA_INITIAL_ROUTE)) { return getIntent().getStringExtra(EXTRA_INITIAL_ROUTE); } try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; + Bundle metaData = getMetaData(); String desiredInitialRoute = - metadata != null ? metadata.getString(INITIAL_ROUTE_META_DATA_KEY) : null; - return desiredInitialRoute != null ? desiredInitialRoute : DEFAULT_INITIAL_ROUTE; + metaData != null ? metaData.getString(INITIAL_ROUTE_META_DATA_KEY) : null; + return desiredInitialRoute; } catch (PackageManager.NameNotFoundException e) { - return DEFAULT_INITIAL_ROUTE; + return null; } } @@ -894,6 +890,14 @@ protected FlutterEngine getFlutterEngine() { return delegate.getFlutterEngine(); } + /** Retrieves the meta data specified in the AndroidManifest.xml. */ + @Nullable + protected Bundle getMetaData() throws PackageManager.NameNotFoundException { + ActivityInfo activityInfo = + getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); + return activityInfo.metaData; + } + @Nullable @Override public PlatformPlugin providePlatformPlugin( @@ -970,6 +974,26 @@ public boolean shouldAttachEngineToActivity() { return true; } + /** + * Whether to handle the deeplinking from the {@code Intent} automatically if the {@code + * getInitialRoute} returns null. + * + *

The default implementation looks {@code } called {@link + * FlutterActivityLaunchConfigs#HANDLE_DEEPLINKING_META_DATA_KEY} within the Android manifest + * definition for this {@code FlutterActivity}. + */ + @Override + public boolean shouldHandleDeeplinking() { + try { + Bundle metaData = getMetaData(); + boolean shouldHandleDeeplinking = + metaData != null ? metaData.getBoolean(HANDLE_DEEPLINKING_META_DATA_KEY) : false; + return shouldHandleDeeplinking; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + @Override public void onFlutterSurfaceViewCreated(@NonNull FlutterSurfaceView flutterSurfaceView) { // Hook for subclasses. diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java index 78585172dd683..f48b7e539f4b0 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java @@ -5,10 +5,12 @@ package io.flutter.embedding.android; import static android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW; +import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.DEFAULT_INITIAL_ROUTE; import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -362,19 +364,23 @@ private void doInitialFlutterViewRun() { // So this is expected behavior in many cases. return; } - + String initialRoute = host.getInitialRoute(); + if (initialRoute == null) { + initialRoute = maybeGetInitialRouteFromIntent(host.getActivity().getIntent()); + if (initialRoute == null) { + initialRoute = DEFAULT_INITIAL_ROUTE; + } + } Log.v( TAG, "Executing Dart entrypoint: " + host.getDartEntrypointFunctionName() + ", and sending initial route: " - + host.getInitialRoute()); + + initialRoute); // The engine needs to receive the Flutter app's initial route before executing any // Dart code to ensure that the initial route arrives in time to be applied. - if (host.getInitialRoute() != null) { - flutterEngine.getNavigationChannel().setInitialRoute(host.getInitialRoute()); - } + flutterEngine.getNavigationChannel().setInitialRoute(initialRoute); String appBundlePathOverride = host.getAppBundlePath(); if (appBundlePathOverride == null || appBundlePathOverride.isEmpty()) { @@ -388,6 +394,16 @@ private void doInitialFlutterViewRun() { flutterEngine.getDartExecutor().executeDartEntrypoint(entrypoint); } + private String maybeGetInitialRouteFromIntent(Intent intent) { + if (host.shouldHandleDeeplinking()) { + Uri data = intent.getData(); + if (data != null && !data.toString().isEmpty()) { + return data.toString(); + } + } + return null; + } + /** * Invoke this from {@code Activity#onResume()} or {@code Fragment#onResume()}. * @@ -622,8 +638,12 @@ void onRequestPermissionsResult( void onNewIntent(@NonNull Intent intent) { ensureAlive(); if (flutterEngine != null) { - Log.v(TAG, "Forwarding onNewIntent() to FlutterEngine."); + Log.v(TAG, "Forwarding onNewIntent() to FlutterEngine and sending pushRoute message."); flutterEngine.getActivityControlSurface().onNewIntent(intent); + String initialRoute = maybeGetInitialRouteFromIntent(intent); + if (initialRoute != null && !initialRoute.isEmpty()) { + flutterEngine.getNavigationChannel().pushRoute(initialRoute); + } } else { Log.w(TAG, "onNewIntent() invoked before FlutterFragment was attached to an Activity."); } @@ -735,6 +755,10 @@ private void ensureAlive() { @NonNull Context getContext(); + /** Returns true if the delegate should retrieve the initial route from the {@link Intent}. */ + @Nullable + boolean shouldHandleDeeplinking(); + /** * Returns the host {@link Activity} or the {@code Activity} that is currently attached to the * host {@code Fragment}. diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterActivityLaunchConfigs.java b/shell/platform/android/io/flutter/embedding/android/FlutterActivityLaunchConfigs.java index ae7a4a48e67a8..a92e342333765 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterActivityLaunchConfigs.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterActivityLaunchConfigs.java @@ -16,7 +16,8 @@ public class FlutterActivityLaunchConfigs { "io.flutter.embedding.android.SplashScreenDrawable"; /* package */ static final String NORMAL_THEME_META_DATA_KEY = "io.flutter.embedding.android.NormalTheme"; - + /* package */ static final String HANDLE_DEEPLINKING_META_DATA_KEY = + "flutter_deeplinking_enabled"; // Intent extra arguments. /* package */ static final String EXTRA_INITIAL_ROUTE = "route"; /* package */ static final String EXTRA_BACKGROUND_MODE = "background_mode"; diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java b/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java index 4d2438bd95935..f47dd8f0f280f 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterFragment.java @@ -88,6 +88,8 @@ public class FlutterFragment extends Fragment implements FlutterActivityAndFragm protected static final String ARG_DART_ENTRYPOINT = "dart_entrypoint"; /** Initial Flutter route that is rendered in a Navigator widget. */ protected static final String ARG_INITIAL_ROUTE = "initial_route"; + /** Whether the activity delegate should handle the deeplinking request. */ + protected static final String ARG_HANDLE_DEEPLINKING = "handle_deeplinking"; /** Path to Flutter's Dart code. */ protected static final String ARG_APP_BUNDLE_PATH = "app_bundle_path"; /** Flutter shell arguments. */ @@ -185,6 +187,7 @@ public static class NewEngineFragmentBuilder { private final Class fragmentClass; private String dartEntrypoint = "main"; private String initialRoute = "/"; + private boolean handleDeeplinking = false; private String appBundlePath = null; private FlutterShellArgs shellArgs = null; private RenderMode renderMode = RenderMode.surface; @@ -224,6 +227,16 @@ public NewEngineFragmentBuilder initialRoute(@NonNull String initialRoute) { return this; } + /** + * Whether to handle the deeplinking from the {@code Intent} automatically if the {@code + * getInitialRoute} returns null. + */ + @NonNull + public NewEngineFragmentBuilder handleDeeplinking(@NonNull Boolean handleDeeplinking) { + this.handleDeeplinking = handleDeeplinking; + return this; + } + /** * The path to the app bundle which contains the Dart app to execute. Null when unspecified, * which defaults to {@link FlutterLoader#findAppBundlePath()} @@ -316,6 +329,7 @@ public NewEngineFragmentBuilder shouldAttachEngineToActivity( protected Bundle createArgs() { Bundle args = new Bundle(); args.putString(ARG_INITIAL_ROUTE, initialRoute); + args.putBoolean(ARG_HANDLE_DEEPLINKING, handleDeeplinking); args.putString(ARG_APP_BUNDLE_PATH, appBundlePath); args.putString(ARG_DART_ENTRYPOINT, dartEntrypoint); // TODO(mattcarroll): determine if we should have an explicit FlutterTestFragment instead of @@ -409,6 +423,7 @@ public static class CachedEngineFragmentBuilder { private final Class fragmentClass; private final String engineId; private boolean destroyEngineWithFragment = false; + private boolean handleDeeplinking = false; private RenderMode renderMode = RenderMode.surface; private TransparencyMode transparencyMode = TransparencyMode.transparent; private boolean shouldAttachEngineToActivity = true; @@ -460,6 +475,16 @@ public CachedEngineFragmentBuilder transparencyMode( return this; } + /** + * Whether to handle the deeplinking from the {@code Intent} automatically if the {@code + * getInitialRoute} returns null. + */ + @NonNull + public CachedEngineFragmentBuilder handleDeeplinking(@NonNull Boolean handleDeeplinking) { + this.handleDeeplinking = handleDeeplinking; + return this; + } + /** * Whether or not this {@code FlutterFragment} should automatically attach its {@code Activity} * as a control surface for its {@link FlutterEngine}. @@ -512,6 +537,7 @@ protected Bundle createArgs() { Bundle args = new Bundle(); args.putString(ARG_CACHED_ENGINE_ID, engineId); args.putBoolean(ARG_DESTROY_ENGINE_WITH_FRAGMENT, destroyEngineWithFragment); + args.putBoolean(ARG_HANDLE_DEEPLINKING, handleDeeplinking); args.putString( ARG_FLUTTERVIEW_RENDER_MODE, renderMode != null ? renderMode.name() : RenderMode.surface.name()); @@ -1016,6 +1042,15 @@ public boolean shouldAttachEngineToActivity() { return getArguments().getBoolean(ARG_SHOULD_ATTACH_ENGINE_TO_ACTIVITY); } + /** + * Whether to handle the deeplinking from the {@code Intent} automatically if the {@code + * getInitialRoute} returns null. + */ + @Override + public boolean shouldHandleDeeplinking() { + return getArguments().getBoolean(ARG_HANDLE_DEEPLINKING); + } + @Override public void onFlutterSurfaceViewCreated(@NonNull FlutterSurfaceView flutterSurfaceView) { // Hook for subclasses. diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterFragmentActivity.java b/shell/platform/android/io/flutter/embedding/android/FlutterFragmentActivity.java index ff15581a57b22..f5ef1711e2592 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterFragmentActivity.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterFragmentActivity.java @@ -12,6 +12,7 @@ import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_CACHED_ENGINE_ID; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_DESTROY_ENGINE_WITH_ACTIVITY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.EXTRA_INITIAL_ROUTE; +import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.HANDLE_DEEPLINKING_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.INITIAL_ROUTE_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.NORMAL_THEME_META_DATA_KEY; import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.SPLASH_SCREEN_META_DATA_KEY; @@ -33,6 +34,7 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import io.flutter.Log; @@ -279,10 +281,9 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { */ private void switchLaunchThemeForNormalTheme() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - if (activityInfo.metaData != null) { - int normalThemeRID = activityInfo.metaData.getInt(NORMAL_THEME_META_DATA_KEY, -1); + Bundle metaData = getMetaData(); + if (metaData != null) { + int normalThemeRID = metaData.getInt(NORMAL_THEME_META_DATA_KEY, -1); if (normalThemeRID != -1) { setTheme(normalThemeRID); } @@ -318,11 +319,9 @@ public SplashScreen provideSplashScreen() { @SuppressWarnings("deprecation") private Drawable getSplashScreenFromManifest() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; + Bundle metaData = getMetaData(); Integer splashScreenId = - metadata != null ? metadata.getInt(SPLASH_SCREEN_META_DATA_KEY) : null; + metaData != null ? metaData.getInt(SPLASH_SCREEN_META_DATA_KEY) : null; return splashScreenId != null ? Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP ? getResources().getDrawable(splashScreenId, getTheme()) @@ -423,6 +422,7 @@ protected FlutterFragment createFlutterFragment() { return FlutterFragment.withCachedEngine(getCachedEngineId()) .renderMode(renderMode) .transparencyMode(transparencyMode) + .handleDeeplinking(shouldHandleDeeplinking()) .shouldAttachEngineToActivity(shouldAttachEngineToActivity()) .destroyEngineWithFragment(shouldDestroyEngineWithHost()) .build(); @@ -450,6 +450,7 @@ protected FlutterFragment createFlutterFragment() { .initialRoute(getInitialRoute()) .appBundlePath(getAppBundlePath()) .flutterShellArgs(FlutterShellArgs.fromIntent(getIntent())) + .handleDeeplinking(shouldHandleDeeplinking()) .renderMode(renderMode) .transparencyMode(transparencyMode) .shouldAttachEngineToActivity(shouldAttachEngineToActivity()) @@ -545,6 +546,26 @@ protected boolean shouldAttachEngineToActivity() { return true; } + /** + * Whether to handle the deeplinking from the {@code Intent} automatically if the {@code + * getInitialRoute} returns null. + * + *

The default implementation looks {@code } called {@link + * FlutterActivityLaunchConfigs#HANDLE_DEEPLINKING_META_DATA_KEY} within the Android manifest + * definition for this {@code FlutterFragmentActivity}. + */ + @VisibleForTesting + protected boolean shouldHandleDeeplinking() { + try { + Bundle metaData = getMetaData(); + boolean shouldHandleDeeplinking = + metaData != null ? metaData.getBoolean(HANDLE_DEEPLINKING_META_DATA_KEY) : false; + return shouldHandleDeeplinking; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + /** Hook for subclasses to easily provide a custom {@code FlutterEngine}. */ @Nullable @Override @@ -608,6 +629,14 @@ protected String getAppBundlePath() { return null; } + /** Retrieves the meta data specified in the AndroidManifest.xml. */ + @Nullable + protected Bundle getMetaData() throws PackageManager.NameNotFoundException { + ActivityInfo activityInfo = + getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); + return activityInfo.metaData; + } + /** * The Dart entrypoint that will be executed as soon as the Dart snapshot is loaded. * @@ -620,11 +649,9 @@ protected String getAppBundlePath() { @NonNull public String getDartEntrypointFunctionName() { try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; + Bundle metaData = getMetaData(); String desiredDartEntrypoint = - metadata != null ? metadata.getString(DART_ENTRYPOINT_META_DATA_KEY) : null; + metaData != null ? metaData.getString(DART_ENTRYPOINT_META_DATA_KEY) : null; return desiredDartEntrypoint != null ? desiredDartEntrypoint : DEFAULT_DART_ENTRYPOINT; } catch (PackageManager.NameNotFoundException e) { return DEFAULT_DART_ENTRYPOINT; @@ -651,22 +678,22 @@ public String getDartEntrypointFunctionName() { * have control over the incoming {@code Intent}. * *

Subclasses may override this method to directly control the initial route. + * + *

If this method returns null and the {@code shouldHandleDeeplinking} returns true, the + * initial route is derived from the {@code Intent} through the Intent.getData() instead. */ - @NonNull protected String getInitialRoute() { if (getIntent().hasExtra(EXTRA_INITIAL_ROUTE)) { return getIntent().getStringExtra(EXTRA_INITIAL_ROUTE); } try { - ActivityInfo activityInfo = - getPackageManager().getActivityInfo(getComponentName(), PackageManager.GET_META_DATA); - Bundle metadata = activityInfo.metaData; + Bundle metaData = getMetaData(); String desiredInitialRoute = - metadata != null ? metadata.getString(INITIAL_ROUTE_META_DATA_KEY) : null; - return desiredInitialRoute != null ? desiredInitialRoute : DEFAULT_INITIAL_ROUTE; + metaData != null ? metaData.getString(INITIAL_ROUTE_META_DATA_KEY) : null; + return desiredInitialRoute; } catch (PackageManager.NameNotFoundException e) { - return DEFAULT_INITIAL_ROUTE; + return null; } } 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 4a621b3344107..bf49fb6871c94 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java @@ -15,6 +15,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.net.Uri; import androidx.annotation.NonNull; import androidx.lifecycle.Lifecycle; import io.flutter.FlutterInjector; @@ -43,6 +44,7 @@ import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; +import org.robolectric.android.controller.ActivityController; import org.robolectric.annotation.Config; @Config(manifest = Config.NONE) @@ -71,6 +73,7 @@ public void setup() { when(mockHost.getTransparencyMode()).thenReturn(TransparencyMode.transparent); when(mockHost.provideFlutterEngine(any(Context.class))).thenReturn(mockFlutterEngine); when(mockHost.shouldAttachEngineToActivity()).thenReturn(true); + when(mockHost.shouldHandleDeeplinking()).thenReturn(false); when(mockHost.shouldDestroyEngineWithHost()).thenReturn(true); } @@ -426,6 +429,78 @@ public void itForwardsOnRequestPermissionsResultToFlutterEngine() { .onRequestPermissionsResult(any(Integer.class), any(String[].class), any(int[].class)); } + @Test + public void + itSendsInitialRouteFromIntentOnStartIfNoInitialRouteFromActivityAndShouldHandleDeeplinking() { + Intent intent = FlutterActivity.createDefaultIntent(RuntimeEnvironment.application); + intent.setData(Uri.parse("http://myApp/custom/route")); + + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + + when(mockHost.getActivity()).thenReturn(flutterActivity); + when(mockHost.getInitialRoute()).thenReturn(null); + when(mockHost.shouldHandleDeeplinking()).thenReturn(true); + // Create the real object that we're testing. + FlutterActivityAndFragmentDelegate delegate = new FlutterActivityAndFragmentDelegate(mockHost); + + // --- Execute the behavior under test --- + // The FlutterEngine is setup in onAttach(). + delegate.onAttach(RuntimeEnvironment.application); + // Emulate app start. + delegate.onStart(); + + // Verify that the navigation channel was given the initial route message. + verify(mockFlutterEngine.getNavigationChannel(), times(1)) + .setInitialRoute("http://myApp/custom/route"); + } + + @Test + public void itSendsdefaultInitialRouteOnStartIfNotDeepLinkingFromIntent() { + // Creates an empty intent without launch uri. + Intent intent = FlutterActivity.createDefaultIntent(RuntimeEnvironment.application); + + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + + when(mockHost.getActivity()).thenReturn(flutterActivity); + when(mockHost.getInitialRoute()).thenReturn(null); + when(mockHost.shouldHandleDeeplinking()).thenReturn(true); + // Create the real object that we're testing. + FlutterActivityAndFragmentDelegate delegate = new FlutterActivityAndFragmentDelegate(mockHost); + + // --- Execute the behavior under test --- + // The FlutterEngine is setup in onAttach(). + delegate.onAttach(RuntimeEnvironment.application); + // Emulate app start. + delegate.onStart(); + + // Verify that the navigation channel was given the default initial route message. + verify(mockFlutterEngine.getNavigationChannel(), times(1)).setInitialRoute("/"); + } + + @Test + public void itSendsPushRouteMessageWhenOnNewIntent() { + when(mockHost.shouldHandleDeeplinking()).thenReturn(true); + // Create the real object that we're testing. + FlutterActivityAndFragmentDelegate delegate = new FlutterActivityAndFragmentDelegate(mockHost); + + // --- Execute the behavior under test --- + // The FlutterEngine is setup in onAttach(). + delegate.onAttach(RuntimeEnvironment.application); + + Intent mockIntent = mock(Intent.class); + when(mockIntent.getData()).thenReturn(Uri.parse("http://myApp/custom/route")); + // Emulate the host and call the method that we expect to be forwarded. + delegate.onNewIntent(mockIntent); + + // Verify that the navigation channel was given the push route message. + verify(mockFlutterEngine.getNavigationChannel(), times(1)) + .pushRoute("http://myApp/custom/route"); + } + @Test public void itForwardsOnNewIntentToFlutterEngine() { // Create the real object that we're testing. diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityTest.java index 2fe23de49b303..19720fa7e6e02 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityTest.java @@ -1,5 +1,6 @@ package io.flutter.embedding.android; +import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.HANDLE_DEEPLINKING_META_DATA_KEY; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -7,12 +8,14 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -116,6 +119,58 @@ public void itCreatesNewEngineIntentWithRequestedSettings() { assertEquals(TransparencyMode.transparent, flutterActivity.getTransparencyMode()); } + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase1() + throws PackageManager.NameNotFoundException { + Intent intent = + FlutterActivity.withNewEngine() + .backgroundMode(BackgroundMode.transparent) + .build(RuntimeEnvironment.application); + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + Bundle bundle = new Bundle(); + bundle.putBoolean(HANDLE_DEEPLINKING_META_DATA_KEY, true); + FlutterActivity spyFlutterActivity = spy(flutterActivity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + assertTrue(spyFlutterActivity.shouldHandleDeeplinking()); + } + + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase2() + throws PackageManager.NameNotFoundException { + Intent intent = + FlutterActivity.withNewEngine() + .backgroundMode(BackgroundMode.transparent) + .build(RuntimeEnvironment.application); + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + Bundle bundle = new Bundle(); + bundle.putBoolean(HANDLE_DEEPLINKING_META_DATA_KEY, false); + FlutterActivity spyFlutterActivity = spy(flutterActivity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + assertFalse(spyFlutterActivity.shouldHandleDeeplinking()); + } + + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase3() + throws PackageManager.NameNotFoundException { + Intent intent = + FlutterActivity.withNewEngine() + .backgroundMode(BackgroundMode.transparent) + .build(RuntimeEnvironment.application); + ActivityController activityController = + Robolectric.buildActivity(FlutterActivity.class, intent); + FlutterActivity flutterActivity = activityController.get(); + // Creates an empty bundle. + Bundle bundle = new Bundle(); + FlutterActivity spyFlutterActivity = spy(flutterActivity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + // Empty bundle should return false. + assertFalse(spyFlutterActivity.shouldHandleDeeplinking()); + } + @Test public void itCreatesCachedEngineIntentThatDoesNotDestroyTheEngine() { Intent intent = diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterAndroidComponentTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterAndroidComponentTest.java index 5cede33a1b833..51532b9c96143 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterAndroidComponentTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterAndroidComponentTest.java @@ -358,6 +358,11 @@ public boolean shouldAttachEngineToActivity() { return true; } + @Override + public boolean shouldHandleDeeplinking() { + return false; + } + @Override public boolean shouldRestoreAndSaveState() { return true; diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java index cd311a835588e..448646db9c793 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java @@ -1,12 +1,17 @@ package io.flutter.embedding.android; +import static io.flutter.embedding.android.FlutterActivityLaunchConfigs.HANDLE_DEEPLINKING_META_DATA_KEY; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode; @@ -82,6 +87,46 @@ public void itRegistersPluginsAtConfigurationTime() { assertEquals(activity.getFlutterEngine(), registeredEngines.get(0)); } + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase1() + throws PackageManager.NameNotFoundException { + FlutterFragmentActivity activity = + Robolectric.buildActivity(FlutterFragmentActivityWithProvidedEngine.class).get(); + assertTrue(GeneratedPluginRegistrant.getRegisteredEngines().isEmpty()); + Bundle bundle = new Bundle(); + bundle.putBoolean(HANDLE_DEEPLINKING_META_DATA_KEY, true); + FlutterFragmentActivity spyFlutterActivity = spy(activity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + assertTrue(spyFlutterActivity.shouldHandleDeeplinking()); + } + + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase2() + throws PackageManager.NameNotFoundException { + FlutterFragmentActivity activity = + Robolectric.buildActivity(FlutterFragmentActivityWithProvidedEngine.class).get(); + assertTrue(GeneratedPluginRegistrant.getRegisteredEngines().isEmpty()); + Bundle bundle = new Bundle(); + bundle.putBoolean(HANDLE_DEEPLINKING_META_DATA_KEY, false); + FlutterFragmentActivity spyFlutterActivity = spy(activity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + assertFalse(spyFlutterActivity.shouldHandleDeeplinking()); + } + + @Test + public void itReturnsValueFromMetaDataWhenCallsShouldHandleDeepLinkingCase3() + throws PackageManager.NameNotFoundException { + FlutterFragmentActivity activity = + Robolectric.buildActivity(FlutterFragmentActivityWithProvidedEngine.class).get(); + assertTrue(GeneratedPluginRegistrant.getRegisteredEngines().isEmpty()); + // Creates an empty bundle. + Bundle bundle = new Bundle(); + FlutterFragmentActivity spyFlutterActivity = spy(activity); + when(spyFlutterActivity.getMetaData()).thenReturn(bundle); + // Empty bundle should return false. + assertFalse(spyFlutterActivity.shouldHandleDeeplinking()); + } + static class FlutterFragmentActivityWithProvidedEngine extends FlutterFragmentActivity { @Override protected FlutterFragment createFlutterFragment() { @@ -119,6 +164,11 @@ protected String getInitialRoute() { protected String getAppBundlePath() { return ""; } + + @Override + protected boolean shouldHandleDeeplinking() { + return false; + } } // This is just a compile time check to ensure that it's possible for FlutterFragmentActivity diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java index 9327994e8d9e6..ecaafe02f7e9a 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentTest.java @@ -27,6 +27,7 @@ public void itCreatesDefaultFragmentWithExpectedDefaults() { assertEquals("/", fragment.getInitialRoute()); assertArrayEquals(new String[] {}, fragment.getFlutterShellArgs().toArray()); assertTrue(fragment.shouldAttachEngineToActivity()); + assertFalse(fragment.shouldHandleDeeplinking()); assertNull(fragment.getCachedEngineId()); assertTrue(fragment.shouldDestroyEngineWithHost()); assertEquals(RenderMode.surface, fragment.getRenderMode()); @@ -40,6 +41,7 @@ public void itCreatesNewEngineFragmentWithRequestedSettings() { .dartEntrypoint("custom_entrypoint") .initialRoute("/custom/route") .shouldAttachEngineToActivity(false) + .handleDeeplinking(true) .renderMode(RenderMode.texture) .transparencyMode(TransparencyMode.opaque) .build(); @@ -49,6 +51,7 @@ public void itCreatesNewEngineFragmentWithRequestedSettings() { assertEquals("/custom/route", fragment.getInitialRoute()); assertArrayEquals(new String[] {}, fragment.getFlutterShellArgs().toArray()); assertFalse(fragment.shouldAttachEngineToActivity()); + assertTrue(fragment.shouldHandleDeeplinking()); assertNull(fragment.getCachedEngineId()); assertTrue(fragment.shouldDestroyEngineWithHost()); assertEquals(RenderMode.texture, fragment.getRenderMode());