diff --git a/packages/url_launcher/url_launcher_android/CHANGELOG.md b/packages/url_launcher/url_launcher_android/CHANGELOG.md index 48abee6a10a..040ec106155 100644 --- a/packages/url_launcher/url_launcher_android/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.0.35 + +* Converts method channels to Pigeon. + ## 6.0.34 * Reverts ContextCompat usage that caused flutter/flutter#127014 diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/Messages.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/Messages.java new file mode 100644 index 00000000000..eab0d87f5b3 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/Messages.java @@ -0,0 +1,299 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// Autogenerated from Pigeon (v10.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package io.flutter.plugins.urllauncher; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Map; + +/** Generated class from Pigeon. */ +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression", "serial"}) +public class Messages { + + /** Error class for passing custom error details to Flutter via a thrown PlatformException. */ + public static class FlutterError extends RuntimeException { + + /** The error code. */ + public final String code; + + /** The error details. Must be a datatype supported by the api codec. */ + public final Object details; + + public FlutterError(@NonNull String code, @Nullable String message, @Nullable Object details) { + super(message); + this.code = code; + this.details = details; + } + } + + @NonNull + protected static ArrayList wrapError(@NonNull Throwable exception) { + ArrayList errorList = new ArrayList(3); + if (exception instanceof FlutterError) { + FlutterError error = (FlutterError) exception; + errorList.add(error.code); + errorList.add(error.getMessage()); + errorList.add(error.details); + } else { + errorList.add(exception.toString()); + errorList.add(exception.getClass().getSimpleName()); + errorList.add( + "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception)); + } + return errorList; + } + + /** + * Configuration options for an in-app WebView. + * + *

Generated class from Pigeon that represents data sent in messages. + */ + public static final class WebViewOptions { + private @NonNull Boolean enableJavaScript; + + public @NonNull Boolean getEnableJavaScript() { + return enableJavaScript; + } + + public void setEnableJavaScript(@NonNull Boolean setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"enableJavaScript\" is null."); + } + this.enableJavaScript = setterArg; + } + + private @NonNull Boolean enableDomStorage; + + public @NonNull Boolean getEnableDomStorage() { + return enableDomStorage; + } + + public void setEnableDomStorage(@NonNull Boolean setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"enableDomStorage\" is null."); + } + this.enableDomStorage = setterArg; + } + + private @NonNull Map headers; + + public @NonNull Map getHeaders() { + return headers; + } + + public void setHeaders(@NonNull Map setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"headers\" is null."); + } + this.headers = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + WebViewOptions() {} + + public static final class Builder { + + private @Nullable Boolean enableJavaScript; + + public @NonNull Builder setEnableJavaScript(@NonNull Boolean setterArg) { + this.enableJavaScript = setterArg; + return this; + } + + private @Nullable Boolean enableDomStorage; + + public @NonNull Builder setEnableDomStorage(@NonNull Boolean setterArg) { + this.enableDomStorage = setterArg; + return this; + } + + private @Nullable Map headers; + + public @NonNull Builder setHeaders(@NonNull Map setterArg) { + this.headers = setterArg; + return this; + } + + public @NonNull WebViewOptions build() { + WebViewOptions pigeonReturn = new WebViewOptions(); + pigeonReturn.setEnableJavaScript(enableJavaScript); + pigeonReturn.setEnableDomStorage(enableDomStorage); + pigeonReturn.setHeaders(headers); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList(3); + toListResult.add(enableJavaScript); + toListResult.add(enableDomStorage); + toListResult.add(headers); + return toListResult; + } + + static @NonNull WebViewOptions fromList(@NonNull ArrayList list) { + WebViewOptions pigeonResult = new WebViewOptions(); + Object enableJavaScript = list.get(0); + pigeonResult.setEnableJavaScript((Boolean) enableJavaScript); + Object enableDomStorage = list.get(1); + pigeonResult.setEnableDomStorage((Boolean) enableDomStorage); + Object headers = list.get(2); + pigeonResult.setHeaders((Map) headers); + return pigeonResult; + } + } + + private static class UrlLauncherApiCodec extends StandardMessageCodec { + public static final UrlLauncherApiCodec INSTANCE = new UrlLauncherApiCodec(); + + private UrlLauncherApiCodec() {} + + @Override + protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { + switch (type) { + case (byte) 128: + return WebViewOptions.fromList((ArrayList) readValue(buffer)); + default: + return super.readValueOfType(type, buffer); + } + } + + @Override + protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { + if (value instanceof WebViewOptions) { + stream.write(128); + writeValue(stream, ((WebViewOptions) value).toList()); + } else { + super.writeValue(stream, value); + } + } + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface UrlLauncherApi { + /** Returns true if the URL can definitely be launched. */ + @NonNull + Boolean canLaunchUrl(@NonNull String url); + /** Opens the URL externally, returning true if successful. */ + @NonNull + Boolean launchUrl(@NonNull String url, @NonNull Map headers); + /** Opens the URL in an in-app WebView, returning true if it opens successfully. */ + @NonNull + Boolean openUrlInWebView(@NonNull String url, @NonNull WebViewOptions options); + /** Closes the view opened by [openUrlInSafariViewController]. */ + void closeWebView(); + + /** The codec used by UrlLauncherApi. */ + static @NonNull MessageCodec getCodec() { + return UrlLauncherApiCodec.INSTANCE; + } + /** Sets up an instance of `UrlLauncherApi` to handle messages through the `binaryMessenger`. */ + static void setup(@NonNull BinaryMessenger binaryMessenger, @Nullable UrlLauncherApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + ArrayList args = (ArrayList) message; + String urlArg = (String) args.get(0); + try { + Boolean output = api.canLaunchUrl(urlArg); + wrapped.add(0, output); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.UrlLauncherApi.launchUrl", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + ArrayList args = (ArrayList) message; + String urlArg = (String) args.get(0); + Map headersArg = (Map) args.get(1); + try { + Boolean output = api.launchUrl(urlArg, headersArg); + wrapped.add(0, output); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.UrlLauncherApi.openUrlInWebView", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + ArrayList args = (ArrayList) message; + String urlArg = (String) args.get(0); + WebViewOptions optionsArg = (WebViewOptions) args.get(1); + try { + Boolean output = api.openUrlInWebView(urlArg, optionsArg); + wrapped.add(0, output); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.UrlLauncherApi.closeWebView", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + try { + api.closeWebView(); + wrapped.add(0, null); + } catch (Throwable exception) { + ArrayList wrappedError = wrapError(exception); + wrapped = wrappedError; + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } +} diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java deleted file mode 100644 index 9ff50670bd9..00000000000 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/MethodCallHandlerImpl.java +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package io.flutter.plugins.urllauncher; - -import android.os.Bundle; -import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.plugins.urllauncher.UrlLauncher.LaunchStatus; -import java.util.Map; - -/** - * Translates incoming UrlLauncher MethodCalls into well formed Java function calls for {@link - * UrlLauncher}. - */ -final class MethodCallHandlerImpl implements MethodCallHandler { - private static final String TAG = "MethodCallHandlerImpl"; - private final UrlLauncher urlLauncher; - @Nullable private MethodChannel channel; - - /** Forwards all incoming MethodChannel calls to the given {@code urlLauncher}. */ - MethodCallHandlerImpl(UrlLauncher urlLauncher) { - this.urlLauncher = urlLauncher; - } - - @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { - final String url = call.argument("url"); - switch (call.method) { - case "canLaunch": - onCanLaunch(result, url); - break; - case "launch": - onLaunch(call, result, url); - break; - case "closeWebView": - onCloseWebView(result); - break; - default: - result.notImplemented(); - break; - } - } - - /** - * Registers this instance as a method call handler on the given {@code messenger}. - * - *

Stops any previously started and unstopped calls. - * - *

This should be cleaned with {@link #stopListening} once the messenger is disposed of. - */ - void startListening(BinaryMessenger messenger) { - if (channel != null) { - Log.wtf(TAG, "Setting a method call handler before the last was disposed."); - stopListening(); - } - - channel = new MethodChannel(messenger, "plugins.flutter.io/url_launcher_android"); - channel.setMethodCallHandler(this); - } - - /** - * Clears this instance from listening to method calls. - * - *

Does nothing if {@link #startListening} hasn't been called, or if we're already stopped. - */ - void stopListening() { - if (channel == null) { - Log.d(TAG, "Tried to stop listening when no MethodChannel had been initialized."); - return; - } - - channel.setMethodCallHandler(null); - channel = null; - } - - private void onCanLaunch(@NonNull Result result, @NonNull String url) { - result.success(urlLauncher.canLaunch(url)); - } - - private void onLaunch(@NonNull MethodCall call, @NonNull Result result, @NonNull String url) { - final boolean useWebView = call.argument("useWebView"); - final boolean enableJavaScript = call.argument("enableJavaScript"); - final boolean enableDomStorage = call.argument("enableDomStorage"); - final Map headersMap = call.argument("headers"); - final Bundle headersBundle = extractBundle(headersMap); - - LaunchStatus launchStatus = - urlLauncher.launch(url, headersBundle, useWebView, enableJavaScript, enableDomStorage); - - if (launchStatus == LaunchStatus.NO_ACTIVITY) { - result.error("NO_ACTIVITY", "Launching a URL requires a foreground activity.", null); - } else if (launchStatus == LaunchStatus.ACTIVITY_NOT_FOUND) { - result.error( - "ACTIVITY_NOT_FOUND", "No Activity found to handle intent { " + url + " }", null); - } else { - result.success(true); - } - } - - private void onCloseWebView(Result result) { - urlLauncher.closeWebView(); - result.success(null); - } - - private static @NonNull Bundle extractBundle(Map headersMap) { - final Bundle headersBundle = new Bundle(); - for (String key : headersMap.keySet()) { - final String value = headersMap.get(key); - headersBundle.putString(key, value); - } - return headersBundle; - } -} diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java index 4e22fda46b1..bb280a8e6d7 100644 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncher.java @@ -15,99 +15,122 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import io.flutter.plugins.urllauncher.Messages.UrlLauncherApi; +import io.flutter.plugins.urllauncher.Messages.WebViewOptions; +import java.util.Map; + +/** Implements the Pigeon-defined interface for calls from Dart. */ +final class UrlLauncher implements UrlLauncherApi { + @VisibleForTesting + interface IntentResolver { + String getHandlerComponentName(@NonNull Intent intent); + } -/** Launches components for URLs. */ -class UrlLauncher { private static final String TAG = "UrlLauncher"; - private final Context applicationContext; - @Nullable private Activity activity; + private final @NonNull Context applicationContext; + + private final @NonNull IntentResolver intentResolver; + + private @Nullable Activity activity; /** - * Uses the given {@code applicationContext} for launching intents. - * - *

It may be null initially, but should be set before calling {@link #launch}. + * Creates an instance that uses {@code intentResolver} to look up the handler for intents. This + * is to allow injecting an alternate resolver for unit testing. */ - UrlLauncher(Context applicationContext, @Nullable Activity activity) { - this.applicationContext = applicationContext; - this.activity = activity; + @VisibleForTesting + UrlLauncher(@NonNull Context context, @NonNull IntentResolver intentResolver) { + this.applicationContext = context; + this.intentResolver = intentResolver; + } + + UrlLauncher(@NonNull Context context) { + this( + context, + intent -> { + ComponentName componentName = intent.resolveActivity(context.getPackageManager()); + return componentName == null ? null : componentName.toShortString(); + }); } void setActivity(@Nullable Activity activity) { this.activity = activity; } - /** Returns whether the given {@code url} resolves into an existing component. */ - boolean canLaunch(@NonNull String url) { + @Override + public @NonNull Boolean canLaunchUrl(@NonNull String url) { Intent launchIntent = new Intent(Intent.ACTION_VIEW); launchIntent.setData(Uri.parse(url)); - ComponentName componentName = - launchIntent.resolveActivity(applicationContext.getPackageManager()); - + String componentName = intentResolver.getHandlerComponentName(launchIntent); + if (BuildConfig.DEBUG) { + Log.i(TAG, "component name for " + url + " is " + componentName); + } if (componentName == null) { - Log.i(TAG, "component name for " + url + " is null"); return false; } else { - Log.i(TAG, "component name for " + url + " is " + componentName.toShortString()); - return !"{com.android.fallback/com.android.fallback.Fallback}" - .equals(componentName.toShortString()); + // Ignore the emulator fallback activity. + return !"{com.android.fallback/com.android.fallback.Fallback}".equals(componentName); } } - /** - * Attempts to launch the given {@code url}. - * - * @param headersBundle forwarded to the intent as {@code Browser.EXTRA_HEADERS}. - * @param useWebView when true, the URL is launched inside of {@link WebViewActivity}. - * @param enableJavaScript Only used if {@param useWebView} is true. Enables JS in the WebView. - * @param enableDomStorage Only used if {@param useWebView} is true. Enables DOM storage in the - * @return {@link LaunchStatus#NO_ACTIVITY} if there's no available {@code applicationContext}. - * {@link LaunchStatus#ACTIVITY_NOT_FOUND} if there's no activity found to handle {@code - * launchIntent}. {@link LaunchStatus#OK} otherwise. - */ - LaunchStatus launch( - @NonNull String url, - @NonNull Bundle headersBundle, - boolean useWebView, - boolean enableJavaScript, - boolean enableDomStorage) { - if (activity == null) { - return LaunchStatus.NO_ACTIVITY; - } + @Override + public @NonNull Boolean launchUrl(@NonNull String url, @NonNull Map headers) { + ensureActivity(); + assert activity != null; - Intent launchIntent; - if (useWebView) { - launchIntent = - WebViewActivity.createIntent( - activity, url, enableJavaScript, enableDomStorage, headersBundle); - } else { - launchIntent = - new Intent(Intent.ACTION_VIEW) - .setData(Uri.parse(url)) - .putExtra(Browser.EXTRA_HEADERS, headersBundle); + Intent launchIntent = + new Intent(Intent.ACTION_VIEW) + .setData(Uri.parse(url)) + .putExtra(Browser.EXTRA_HEADERS, extractBundle(headers)); + try { + activity.startActivity(launchIntent); + } catch (ActivityNotFoundException e) { + return false; } + return true; + } + + @Override + public @NonNull Boolean openUrlInWebView(@NonNull String url, @NonNull WebViewOptions options) { + ensureActivity(); + assert activity != null; + + Intent launchIntent = + WebViewActivity.createIntent( + activity, + url, + options.getEnableJavaScript(), + options.getEnableDomStorage(), + extractBundle(options.getHeaders())); try { activity.startActivity(launchIntent); } catch (ActivityNotFoundException e) { - return LaunchStatus.ACTIVITY_NOT_FOUND; + return false; } - return LaunchStatus.OK; + return true; } - /** Closes any activities started with {@link #launch} {@code useWebView=true}. */ - void closeWebView() { + @Override + public void closeWebView() { applicationContext.sendBroadcast(new Intent(WebViewActivity.ACTION_CLOSE)); } - /** Result of a {@link #launch} call. */ - enum LaunchStatus { - /** The intent was well formed. */ - OK, - /** No activity was found to launch. */ - NO_ACTIVITY, - /** No Activity found that can handle given intent. */ - ACTIVITY_NOT_FOUND, + private static @NonNull Bundle extractBundle(Map headersMap) { + final Bundle headersBundle = new Bundle(); + for (String key : headersMap.keySet()) { + final String value = headersMap.get(key); + headersBundle.putString(key, value); + } + return headersBundle; + } + + private void ensureActivity() { + if (activity == null) { + throw new Messages.FlutterError( + "NO_ACTIVITY", "Launching a URL requires a foreground activity.", null); + } } } diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java index 517ef8d7fc9..f2160ec4df2 100644 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/UrlLauncherPlugin.java @@ -18,7 +18,6 @@ */ public final class UrlLauncherPlugin implements FlutterPlugin, ActivityAware { private static final String TAG = "UrlLauncherPlugin"; - @Nullable private MethodCallHandlerImpl methodCallHandler; @Nullable private UrlLauncher urlLauncher; /** @@ -31,49 +30,43 @@ public final class UrlLauncherPlugin implements FlutterPlugin, ActivityAware { @SuppressWarnings("deprecation") public static void registerWith( @NonNull io.flutter.plugin.common.PluginRegistry.Registrar registrar) { - MethodCallHandlerImpl handler = - new MethodCallHandlerImpl(new UrlLauncher(registrar.context(), registrar.activity())); - handler.startListening(registrar.messenger()); + UrlLauncher handler = new UrlLauncher(registrar.context()); + handler.setActivity(registrar.activity()); + Messages.UrlLauncherApi.setup(registrar.messenger(), handler); } @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { - urlLauncher = new UrlLauncher(binding.getApplicationContext(), /*activity=*/ null); - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - methodCallHandler.startListening(binding.getBinaryMessenger()); + urlLauncher = new UrlLauncher(binding.getApplicationContext()); + Messages.UrlLauncherApi.setup(binding.getBinaryMessenger(), urlLauncher); } @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - if (methodCallHandler == null) { + if (urlLauncher == null) { Log.wtf(TAG, "Already detached from the engine."); return; } - methodCallHandler.stopListening(); - methodCallHandler = null; + Messages.UrlLauncherApi.setup(binding.getBinaryMessenger(), null); urlLauncher = null; } @Override public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { - if (methodCallHandler == null) { + if (urlLauncher == null) { Log.wtf(TAG, "urlLauncher was never set."); return; } - - assert urlLauncher != null; urlLauncher.setActivity(binding.getActivity()); } @Override public void onDetachedFromActivity() { - if (methodCallHandler == null) { + if (urlLauncher == null) { Log.wtf(TAG, "urlLauncher was never set."); return; } - - assert urlLauncher != null; urlLauncher.setActivity(null); } diff --git a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java index 5371d3e14fe..63b6b71552e 100644 --- a/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java +++ b/packages/url_launcher/url_launcher_android/android/src/main/java/io/flutter/plugins/urllauncher/WebViewActivity.java @@ -174,9 +174,11 @@ public boolean onKeyDown(int keyCode, @Nullable KeyEvent event) { return super.onKeyDown(keyCode, event); } - private static final String URL_EXTRA = "url"; - private static final String ENABLE_JS_EXTRA = "enableJavaScript"; - private static final String ENABLE_DOM_EXTRA = "enableDomStorage"; + @VisibleForTesting static final String URL_EXTRA = "url"; + + @VisibleForTesting static final String ENABLE_JS_EXTRA = "enableJavaScript"; + + @VisibleForTesting static final String ENABLE_DOM_EXTRA = "enableDomStorage"; /* Hides the constants used to forward data to the Activity instance. */ public static @NonNull Intent createIntent( diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java deleted file mode 100644 index 6bd88b65080..00000000000 --- a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/MethodCallHandlerImplTest.java +++ /dev/null @@ -1,217 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package io.flutter.plugins.urllauncher; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -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 android.os.Bundle; -import androidx.test.core.app.ApplicationProvider; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.BinaryMessenger.BinaryMessageHandler; -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel.Result; -import java.util.HashMap; -import java.util.Map; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -@RunWith(RobolectricTestRunner.class) -public class MethodCallHandlerImplTest { - private static final String CHANNEL_NAME = "plugins.flutter.io/url_launcher_android"; - private UrlLauncher urlLauncher; - private MethodCallHandlerImpl methodCallHandler; - - @Before - public void setUp() { - urlLauncher = new UrlLauncher(ApplicationProvider.getApplicationContext(), /*activity=*/ null); - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - } - - @Test - public void startListening_registersChannel() { - BinaryMessenger messenger = mock(BinaryMessenger.class); - - methodCallHandler.startListening(messenger); - - verify(messenger, times(1)) - .setMessageHandler(eq(CHANNEL_NAME), any(BinaryMessageHandler.class)); - } - - @Test - public void startListening_unregistersExistingChannel() { - BinaryMessenger firstMessenger = mock(BinaryMessenger.class); - BinaryMessenger secondMessenger = mock(BinaryMessenger.class); - methodCallHandler.startListening(firstMessenger); - - methodCallHandler.startListening(secondMessenger); - - // Unregisters the first and then registers the second. - verify(firstMessenger, times(1)).setMessageHandler(CHANNEL_NAME, null); - verify(secondMessenger, times(1)) - .setMessageHandler(eq(CHANNEL_NAME), any(BinaryMessageHandler.class)); - } - - @Test - public void stopListening_unregistersExistingChannel() { - BinaryMessenger messenger = mock(BinaryMessenger.class); - methodCallHandler.startListening(messenger); - - methodCallHandler.stopListening(); - - verify(messenger, times(1)).setMessageHandler(CHANNEL_NAME, null); - } - - @Test - public void stopListening_doesNothingWhenUnset() { - BinaryMessenger messenger = mock(BinaryMessenger.class); - - methodCallHandler.stopListening(); - - verify(messenger, never()).setMessageHandler(CHANNEL_NAME, null); - } - - @Test - public void onMethodCall_canLaunchReturnsTrue() { - urlLauncher = mock(UrlLauncher.class); - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - String url = "foo"; - when(urlLauncher.canLaunch(url)).thenReturn(true); - Result result = mock(Result.class); - Map args = new HashMap<>(); - args.put("url", url); - - methodCallHandler.onMethodCall(new MethodCall("canLaunch", args), result); - - verify(result, times(1)).success(true); - } - - @Test - public void onMethodCall_canLaunchReturnsFalse() { - urlLauncher = mock(UrlLauncher.class); - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - String url = "foo"; - when(urlLauncher.canLaunch(url)).thenReturn(false); - Result result = mock(Result.class); - Map args = new HashMap<>(); - args.put("url", url); - - methodCallHandler.onMethodCall(new MethodCall("canLaunch", args), result); - - verify(result, times(1)).success(false); - } - - @Test - public void onMethodCall_launchReturnsNoActivityError() { - // Setup mock objects - urlLauncher = mock(UrlLauncher.class); - Result result = mock(Result.class); - // Setup expected values - String url = "foo"; - boolean useWebView = false; - boolean enableJavaScript = false; - boolean enableDomStorage = false; - // Setup arguments map send on the method channel - Map args = new HashMap<>(); - args.put("url", url); - args.put("useWebView", useWebView); - args.put("enableJavaScript", enableJavaScript); - args.put("enableDomStorage", enableDomStorage); - args.put("headers", new HashMap<>()); - // Mock the launch method on the urlLauncher class - when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) - .thenReturn(UrlLauncher.LaunchStatus.NO_ACTIVITY); - // Act by calling the "launch" method on the method channel - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - methodCallHandler.onMethodCall(new MethodCall("launch", args), result); - // Verify the results and assert - verify(result, times(1)) - .error("NO_ACTIVITY", "Launching a URL requires a foreground activity.", null); - } - - @Test - public void onMethodCall_launchReturnsActivityNotFoundError() { - // Setup mock objects - urlLauncher = mock(UrlLauncher.class); - Result result = mock(Result.class); - // Setup expected values - String url = "foo"; - boolean useWebView = false; - boolean enableJavaScript = false; - boolean enableDomStorage = false; - // Setup arguments map send on the method channel - Map args = new HashMap<>(); - args.put("url", url); - args.put("useWebView", useWebView); - args.put("enableJavaScript", enableJavaScript); - args.put("enableDomStorage", enableDomStorage); - args.put("headers", new HashMap<>()); - // Mock the launch method on the urlLauncher class - when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) - .thenReturn(UrlLauncher.LaunchStatus.ACTIVITY_NOT_FOUND); - // Act by calling the "launch" method on the method channel - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - methodCallHandler.onMethodCall(new MethodCall("launch", args), result); - // Verify the results and assert - verify(result, times(1)) - .error( - "ACTIVITY_NOT_FOUND", - String.format("No Activity found to handle intent { %s }", url), - null); - } - - @Test - public void onMethodCall_launchReturnsTrue() { - // Setup mock objects - urlLauncher = mock(UrlLauncher.class); - Result result = mock(Result.class); - // Setup expected values - String url = "foo"; - boolean useWebView = false; - boolean enableJavaScript = false; - boolean enableDomStorage = false; - // Setup arguments map send on the method channel - Map args = new HashMap<>(); - args.put("url", url); - args.put("useWebView", useWebView); - args.put("enableJavaScript", enableJavaScript); - args.put("enableDomStorage", enableDomStorage); - args.put("headers", new HashMap<>()); - // Mock the launch method on the urlLauncher class - when(urlLauncher.launch( - eq(url), any(Bundle.class), eq(useWebView), eq(enableJavaScript), eq(enableDomStorage))) - .thenReturn(UrlLauncher.LaunchStatus.OK); - // Act by calling the "launch" method on the method channel - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - methodCallHandler.onMethodCall(new MethodCall("launch", args), result); - // Verify the results and assert - verify(result, times(1)).success(true); - } - - @Test - public void onMethodCall_closeWebView() { - urlLauncher = mock(UrlLauncher.class); - methodCallHandler = new MethodCallHandlerImpl(urlLauncher); - String url = "foo"; - when(urlLauncher.canLaunch(url)).thenReturn(true); - Result result = mock(Result.class); - Map args = new HashMap<>(); - args.put("url", url); - - methodCallHandler.onMethodCall(new MethodCall("closeWebView", args), result); - - verify(urlLauncher, times(1)).closeWebView(); - verify(result, times(1)).success(null); - } -} diff --git a/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java new file mode 100644 index 00000000000..e87def079f2 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/android/src/test/java/io/flutter/plugins/urllauncher/UrlLauncherTest.java @@ -0,0 +1,281 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.urllauncher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Browser; +import androidx.test.core.app.ApplicationProvider; +import java.util.HashMap; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class UrlLauncherTest { + @Test + public void canLaunch_createsIntentWithPassedUrl() { + UrlLauncher.IntentResolver resolver = mock(UrlLauncher.IntentResolver.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext(), resolver); + Uri url = Uri.parse("https://flutter.dev"); + when(resolver.getHandlerComponentName(any())).thenReturn(null); + + api.canLaunchUrl(url.toString()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(resolver).getHandlerComponentName(intentCaptor.capture()); + assertEquals(url, intentCaptor.getValue().getData()); + } + + @Test + public void canLaunch_returnsTrue() { + UrlLauncher api = + new UrlLauncher(ApplicationProvider.getApplicationContext(), intent -> "some.component"); + + Boolean result = api.canLaunchUrl("https://flutter.dev"); + + assertTrue(result); + } + + @Test + public void canLaunch_returnsFalse() { + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext(), intent -> null); + + Boolean result = api.canLaunchUrl("https://flutter.dev"); + + assertFalse(result); + } + + // Integration testing on emulators won't work as expected without the workaround this tests + // for, since it will be returned even for intentionally bogus schemes. + @Test + public void canLaunch_returnsFalseForEmulatorFallbackComponent() { + UrlLauncher api = + new UrlLauncher( + ApplicationProvider.getApplicationContext(), + intent -> "{com.android.fallback/com.android.fallback.Fallback}"); + + Boolean result = api.canLaunchUrl("https://flutter.dev"); + + assertFalse(result); + } + + @Test + public void launch_throwsForNoCurrentActivity() { + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(null); + + Messages.FlutterError exception = + assertThrows( + Messages.FlutterError.class, + () -> api.launchUrl("https://flutter.dev", new HashMap<>())); + assertEquals("NO_ACTIVITY", exception.code); + } + + @Test + public void launch_createsIntentWithPassedUrl() { + Activity activity = mock(Activity.class); + String url = "https://flutter.dev"; + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + doThrow(new ActivityNotFoundException()).when(activity).startActivity(any()); + + api.launchUrl("https://flutter.dev", new HashMap<>()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + assertEquals(url, intentCaptor.getValue().getData().toString()); + } + + @Test + public void launch_returnsFalse() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + doThrow(new ActivityNotFoundException()).when(activity).startActivity(any()); + + boolean result = api.launchUrl("https://flutter.dev", new HashMap<>()); + + assertFalse(result); + } + + @Test + public void launch_returnsTrue() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + + boolean result = api.launchUrl("https://flutter.dev", new HashMap<>()); + + assertTrue(result); + } + + @Test + public void openWebView_opensUrl() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + String url = "https://flutter.dev"; + boolean enableJavaScript = false; + boolean enableDomStorage = false; + + boolean result = + api.openUrlInWebView( + url, + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(enableJavaScript) + .setEnableDomStorage(enableDomStorage) + .setHeaders(new HashMap<>()) + .build()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + assertTrue(result); + assertEquals(url, intentCaptor.getValue().getExtras().getString(WebViewActivity.URL_EXTRA)); + assertEquals( + enableJavaScript, + intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_JS_EXTRA)); + assertEquals( + enableDomStorage, + intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA)); + } + + @Test + public void openWebView_handlesEnableJavaScript() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + boolean enableJavaScript = true; + + api.openUrlInWebView( + "https://flutter.dev", + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(enableJavaScript) + .setEnableDomStorage(false) + .setHeaders(new HashMap<>()) + .build()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + assertEquals( + enableJavaScript, + intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_JS_EXTRA)); + } + + @Test + public void openWebView_handlesHeaders() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + HashMap headers = new HashMap<>(); + final String key1 = "key"; + final String key2 = "key2"; + headers.put(key1, "value"); + headers.put(key2, "value2"); + + api.openUrlInWebView( + "https://flutter.dev", + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(false) + .setEnableDomStorage(false) + .setHeaders(headers) + .build()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + final Bundle passedHeaders = + intentCaptor.getValue().getExtras().getBundle(Browser.EXTRA_HEADERS); + assertEquals(headers.size(), passedHeaders.size()); + assertEquals(headers.get(key1), passedHeaders.getString(key1)); + assertEquals(headers.get(key2), passedHeaders.getString(key2)); + } + + @Test + public void openWebView_handlesEnableDomStorage() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + boolean enableDomStorage = true; + + api.openUrlInWebView( + "https://flutter.dev", + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(false) + .setEnableDomStorage(enableDomStorage) + .setHeaders(new HashMap<>()) + .build()); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + assertEquals( + enableDomStorage, + intentCaptor.getValue().getExtras().getBoolean(WebViewActivity.ENABLE_DOM_EXTRA)); + } + + @Test + public void openWebView_throwsForNoCurrentActivity() { + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(null); + + Messages.FlutterError exception = + assertThrows( + Messages.FlutterError.class, + () -> + api.openUrlInWebView( + "https://flutter.dev", + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(false) + .setEnableDomStorage(false) + .setHeaders(new HashMap<>()) + .build())); + assertEquals("NO_ACTIVITY", exception.code); + } + + @Test + public void openWebView_returnsFalse() { + Activity activity = mock(Activity.class); + UrlLauncher api = new UrlLauncher(ApplicationProvider.getApplicationContext()); + api.setActivity(activity); + doThrow(new ActivityNotFoundException()).when(activity).startActivity(any()); + + boolean result = + api.openUrlInWebView( + "https://flutter.dev", + new Messages.WebViewOptions.Builder() + .setEnableJavaScript(false) + .setEnableDomStorage(false) + .setHeaders(new HashMap<>()) + .build()); + + assertFalse(result); + } + + @Test + public void closeWebView_closes() { + Context context = mock(Context.class); + UrlLauncher api = new UrlLauncher(context); + + api.closeWebView(); + + final ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(context).sendBroadcast(intentCaptor.capture()); + assertEquals(WebViewActivity.ACTION_CLOSE, intentCaptor.getValue().getAction()); + } +} diff --git a/packages/url_launcher/url_launcher_android/lib/src/messages.g.dart b/packages/url_launcher/url_launcher_android/lib/src/messages.g.dart new file mode 100644 index 00000000000..9aed8f7f60f --- /dev/null +++ b/packages/url_launcher/url_launcher_android/lib/src/messages.g.dart @@ -0,0 +1,187 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// Autogenerated from Pigeon (v10.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +/// Configuration options for an in-app WebView. +class WebViewOptions { + WebViewOptions({ + required this.enableJavaScript, + required this.enableDomStorage, + required this.headers, + }); + + bool enableJavaScript; + + bool enableDomStorage; + + Map headers; + + Object encode() { + return [ + enableJavaScript, + enableDomStorage, + headers, + ]; + } + + static WebViewOptions decode(Object result) { + result as List; + return WebViewOptions( + enableJavaScript: result[0]! as bool, + enableDomStorage: result[1]! as bool, + headers: (result[2] as Map?)!.cast(), + ); + } +} + +class _UrlLauncherApiCodec extends StandardMessageCodec { + const _UrlLauncherApiCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is WebViewOptions) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return WebViewOptions.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class UrlLauncherApi { + /// Constructor for [UrlLauncherApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + UrlLauncherApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _UrlLauncherApiCodec(); + + /// Returns true if the URL can definitely be launched. + Future canLaunchUrl(String arg_url) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.canLaunchUrl', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_url]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyList[0] as bool?)!; + } + } + + /// Opens the URL externally, returning true if successful. + Future launchUrl( + String arg_url, Map arg_headers) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.launchUrl', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_url, arg_headers]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyList[0] as bool?)!; + } + } + + /// Opens the URL in an in-app WebView, returning true if it opens + /// successfully. + Future openUrlInWebView( + String arg_url, WebViewOptions arg_options) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.openUrlInWebView', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_url, arg_options]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyList[0] as bool?)!; + } + } + + /// Closes the view opened by [openUrlInSafariViewController]. + Future closeWebView() async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.UrlLauncherApi.closeWebView', codec, + binaryMessenger: _binaryMessenger); + final List? replyList = await channel.send(null) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } +} diff --git a/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart b/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart index bd4c2a5ff45..7b53b85f793 100644 --- a/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart +++ b/packages/url_launcher/url_launcher_android/lib/url_launcher_android.dart @@ -2,17 +2,22 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; -const MethodChannel _channel = - MethodChannel('plugins.flutter.io/url_launcher_android'); +import 'src/messages.g.dart'; /// An implementation of [UrlLauncherPlatform] for Android. class UrlLauncherAndroid extends UrlLauncherPlatform { + /// Creates a new plugin implementation instance. + UrlLauncherAndroid({ + @visibleForTesting UrlLauncherApi? api, + }) : _hostApi = api ?? UrlLauncherApi(); + + final UrlLauncherApi _hostApi; + /// Registers this class as the default instance of [UrlLauncherPlatform]. static void registerWith() { UrlLauncherPlatform.instance = UrlLauncherAndroid(); @@ -23,7 +28,7 @@ class UrlLauncherAndroid extends UrlLauncherPlatform { @override Future canLaunch(String url) async { - final bool canLaunchSpecificUrl = await _canLaunchUrl(url); + final bool canLaunchSpecificUrl = await _hostApi.canLaunchUrl(url); if (!canLaunchSpecificUrl) { final String scheme = _getUrlScheme(url); // canLaunch can return false when a custom application is registered to @@ -33,24 +38,19 @@ class UrlLauncherAndroid extends UrlLauncherPlatform { // returns true, then there is a browser, which means that there is // at least one handler for the original URL. if (scheme == 'http' || scheme == 'https') { - return _canLaunchUrl('$scheme://flutter.dev'); + return _hostApi.canLaunchUrl('$scheme://flutter.dev'); } } return canLaunchSpecificUrl; } - Future _canLaunchUrl(String url) { - return _channel.invokeMethod( - 'canLaunch', - {'url': url}, - ).then((bool? value) => value ?? false); - } - @override Future closeWebView() { - return _channel.invokeMethod('closeWebView'); + return _hostApi.closeWebView(); } + // TODO(stuartmorgan): Implement launchUrl, and make this a passthrough + // to launchUrl. See also https://github.com/flutter/flutter/issues/66721 @override Future launch( String url, { @@ -61,18 +61,27 @@ class UrlLauncherAndroid extends UrlLauncherPlatform { required bool universalLinksOnly, required Map headers, String? webOnlyWindowName, - }) { - return _channel.invokeMethod( - 'launch', - { - 'url': url, - 'useWebView': useWebView, - 'enableJavaScript': enableJavaScript, - 'enableDomStorage': enableDomStorage, - 'universalLinksOnly': universalLinksOnly, - 'headers': headers, - }, - ).then((bool? value) => value ?? false); + }) async { + final bool succeeded; + if (useWebView) { + succeeded = await _hostApi.openUrlInWebView( + url, + WebViewOptions( + enableJavaScript: enableJavaScript, + enableDomStorage: enableDomStorage, + headers: headers)); + } else { + succeeded = await _hostApi.launchUrl(url, headers); + } + // TODO(stuartmorgan): Remove this special handling as part of a + // breaking change to rework failure handling across all platform. The + // current behavior is backwards compatible with the previous Java error. + if (!succeeded) { + throw PlatformException( + code: 'ACTIVITY_NOT_FOUND', + message: 'No Activity found to handle intent { $url }'); + } + return succeeded; } // Returns the part of [url] up to the first ':', or an empty string if there diff --git a/packages/url_launcher/url_launcher_android/pigeons/copyright.txt b/packages/url_launcher/url_launcher_android/pigeons/copyright.txt new file mode 100644 index 00000000000..1236b63caf3 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/pigeons/copyright.txt @@ -0,0 +1,3 @@ +Copyright 2013 The Flutter Authors. All rights reserved. +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. diff --git a/packages/url_launcher/url_launcher_android/pigeons/messages.dart b/packages/url_launcher/url_launcher_android/pigeons/messages.dart new file mode 100644 index 00000000000..84e507d7024 --- /dev/null +++ b/packages/url_launcher/url_launcher_android/pigeons/messages.dart @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + javaOptions: JavaOptions(package: 'io.flutter.plugins.urllauncher'), + javaOut: 'android/src/main/java/io/flutter/plugins/urllauncher/Messages.java', + copyrightHeader: 'pigeons/copyright.txt', +)) + +/// Configuration options for an in-app WebView. +class WebViewOptions { + const WebViewOptions( + {required this.enableJavaScript, + required this.enableDomStorage, + this.headers = const {}}); + final bool enableJavaScript; + final bool enableDomStorage; + // TODO(stuartmorgan): Declare these as non-nullable generics once + // https://github.com/flutter/flutter/issues/97848 is fixed. In practice, + // the values will never be null, and the native implementation assumes that. + final Map headers; +} + +@HostApi() +abstract class UrlLauncherApi { + /// Returns true if the URL can definitely be launched. + bool canLaunchUrl(String url); + + /// Opens the URL externally, returning true if successful. + bool launchUrl(String url, Map headers); + + /// Opens the URL in an in-app WebView, returning true if it opens + /// successfully. + bool openUrlInWebView(String url, WebViewOptions options); + + /// Closes the view opened by [openUrlInSafariViewController]. + void closeWebView(); +} diff --git a/packages/url_launcher/url_launcher_android/pubspec.yaml b/packages/url_launcher/url_launcher_android/pubspec.yaml index 6b1e168e9bc..fc277e86631 100644 --- a/packages/url_launcher/url_launcher_android/pubspec.yaml +++ b/packages/url_launcher/url_launcher_android/pubspec.yaml @@ -2,7 +2,7 @@ name: url_launcher_android description: Android implementation of the url_launcher plugin. repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22 -version: 6.0.34 +version: 6.0.35 environment: sdk: ">=2.18.0 <4.0.0" @@ -26,5 +26,6 @@ dev_dependencies: flutter_test: sdk: flutter mockito: 5.4.0 + pigeon: ^10.0.0 plugin_platform_interface: ^2.0.0 test: ^1.16.3 diff --git a/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart b/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart index 18db61e0b9f..2b069fb1d60 100644 --- a/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart +++ b/packages/url_launcher/url_launcher_android/test/url_launcher_android_test.dart @@ -4,27 +4,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_android/src/messages.g.dart'; import 'package:url_launcher_android/url_launcher_android.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const MethodChannel channel = - MethodChannel('plugins.flutter.io/url_launcher_android'); - late List log; + late _FakeUrlLauncherApi api; setUp(() { - log = []; - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - - // Return null explicitly instead of relying on the implicit null - // returned by the method channel if no return statement is specified. - return null; - }); + api = _FakeUrlLauncherApi(); }); test('registers instance', () { @@ -33,91 +21,41 @@ void main() { }); group('canLaunch', () { - test('calls through', () async { - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - return true; - }); - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); + test('returns true', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); final bool canLaunch = await launcher.canLaunch('http://example.com/'); - expect( - log, - [ - isMethodCall('canLaunch', arguments: { - 'url': 'http://example.com/', - }) - ], - ); + expect(canLaunch, true); }); - test('returns false if platform returns null', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - final bool canLaunch = await launcher.canLaunch('http://example.com/'); + test('returns false', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + final bool canLaunch = await launcher.canLaunch('unknown://scheme'); expect(canLaunch, false); }); test('checks a generic URL if an http URL returns false', () async { - const String specificUrl = 'http://example.com/'; - const String genericUrl = 'http://flutter.dev'; - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - return (methodCall.arguments as Map)['url'] != - specificUrl; - }); - - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - final bool canLaunch = await launcher.canLaunch(specificUrl); + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + final bool canLaunch = await launcher + .canLaunch('http://${_FakeUrlLauncherApi.specialHandlerDomain}'); expect(canLaunch, true); - expect(log.length, 2); - expect((log[1].arguments as Map)['url'], genericUrl); }); test('checks a generic URL if an https URL returns false', () async { - const String specificUrl = 'https://example.com/'; - const String genericUrl = 'https://flutter.dev'; - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - return (methodCall.arguments as Map)['url'] != - specificUrl; - }); - - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - final bool canLaunch = await launcher.canLaunch(specificUrl); + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + final bool canLaunch = await launcher + .canLaunch('https://${_FakeUrlLauncherApi.specialHandlerDomain}'); expect(canLaunch, true); - expect(log.length, 2); - expect((log[1].arguments as Map)['url'], genericUrl); - }); - - test('does not a generic URL if a non-web URL returns false', () async { - _ambiguate(TestDefaultBinaryMessengerBinding.instance)! - .defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - log.add(methodCall); - return false; - }); - - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - final bool canLaunch = await launcher.canLaunch('sms:12345'); - - expect(canLaunch, false); - expect(log.length, 1); }); }); - group('launch', () { + group('launch without webview', () { test('calls through', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - await launcher.launch( + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + final bool launched = await launcher.launch( 'http://example.com/', useSafariVC: true, useWebView: false, @@ -126,23 +64,13 @@ void main() { universalLinksOnly: false, headers: const {}, ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': false, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + expect(launched, true); + expect(api.usedWebView, false); + expect(api.passedWebViewOptions?.headers, isEmpty); }); test('passes headers', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); await launcher.launch( 'http://example.com/', useSafariVC: true, @@ -152,50 +80,46 @@ void main() { universalLinksOnly: false, headers: const {'key': 'value'}, ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': false, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {'key': 'value'}, - }) - ], - ); + expect(api.passedWebViewOptions?.headers.length, 1); + expect(api.passedWebViewOptions?.headers['key'], 'value'); }); - test('handles universal links only', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - await launcher.launch( - 'http://example.com/', - useSafariVC: false, - useWebView: false, - enableJavaScript: false, - enableDomStorage: false, - universalLinksOnly: true, - headers: const {}, - ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': false, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': true, - 'headers': {}, - }) - ], - ); + test('passes through no-activity exception', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + expectLater( + launcher.launch( + 'noactivity://', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + throwsA(isA())); }); - test('handles force WebView', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - await launcher.launch( + test('throws if there is no handling activity', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + expectLater( + launcher.launch( + 'unknown://scheme', + useSafariVC: false, + useWebView: false, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'ACTIVITY_NOT_FOUND'))); + }); + }); + + group('launch with webview', () { + test('calls through', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + final bool launched = await launcher.launch( 'http://example.com/', useSafariVC: true, useWebView: true, @@ -204,23 +128,15 @@ void main() { universalLinksOnly: false, headers: const {}, ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': true, - 'enableJavaScript': false, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + expect(launched, true); + expect(api.usedWebView, true); + expect(api.passedWebViewOptions?.enableDomStorage, false); + expect(api.passedWebViewOptions?.enableJavaScript, false); + expect(api.passedWebViewOptions?.headers, isEmpty); }); - test('handles force WebView with javascript', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); + test('passes enableJavaScript to webview', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); await launcher.launch( 'http://example.com/', useSafariVC: true, @@ -230,23 +146,12 @@ void main() { universalLinksOnly: false, headers: const {}, ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': true, - 'enableJavaScript': true, - 'enableDomStorage': false, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + + expect(api.passedWebViewOptions?.enableJavaScript, true); }); - test('handles force WebView with DOM storage', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); + test('passes enableDomStorage to webview', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); await launcher.launch( 'http://example.com/', useSafariVC: true, @@ -256,51 +161,98 @@ void main() { universalLinksOnly: false, headers: const {}, ); - expect( - log, - [ - isMethodCall('launch', arguments: { - 'url': 'http://example.com/', - 'useWebView': true, - 'enableJavaScript': false, - 'enableDomStorage': true, - 'universalLinksOnly': false, - 'headers': {}, - }) - ], - ); + + expect(api.passedWebViewOptions?.enableDomStorage, true); }); - test('returns false if platform returns null', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); - final bool launched = await launcher.launch( - 'http://example.com/', - useSafariVC: true, - useWebView: false, - enableJavaScript: false, - enableDomStorage: false, - universalLinksOnly: false, - headers: const {}, - ); + test('passes through no-activity exception', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + expectLater( + launcher.launch( + 'noactivity://scheme', + useSafariVC: false, + useWebView: true, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + throwsA(isA())); + }); - expect(launched, false); + test('throws if there is no handling activity', () async { + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); + expectLater( + launcher.launch( + 'unknown://scheme', + useSafariVC: false, + useWebView: true, + enableJavaScript: false, + enableDomStorage: false, + universalLinksOnly: false, + headers: const {}, + ), + throwsA(isA().having( + (PlatformException e) => e.code, 'code', 'ACTIVITY_NOT_FOUND'))); }); }); group('closeWebView', () { test('calls through', () async { - final UrlLauncherAndroid launcher = UrlLauncherAndroid(); + final UrlLauncherAndroid launcher = UrlLauncherAndroid(api: api); await launcher.closeWebView(); - expect( - log, - [isMethodCall('closeWebView', arguments: null)], - ); + + expect(api.closed, true); }); }); } -/// This allows a value of type T or T? to be treated as a value of type T?. +/// A fake implementation of the host API that reacts to specific schemes. /// -/// We use this so that APIs that have become non-nullable can still be used -/// with `!` and `?` on the stable branch. -T? _ambiguate(T? value) => value; +/// See _launch for the behaviors. +class _FakeUrlLauncherApi implements UrlLauncherApi { + WebViewOptions? passedWebViewOptions; + bool? usedWebView; + bool? closed; + + /// A domain that will be treated as having no handler, even for http(s). + static String specialHandlerDomain = 'special.handler.domain'; + + @override + Future canLaunchUrl(String url) async { + return _launch(url); + } + + @override + Future launchUrl(String url, Map headers) async { + passedWebViewOptions = WebViewOptions( + enableJavaScript: false, enableDomStorage: false, headers: headers); + usedWebView = false; + return _launch(url); + } + + @override + Future closeWebView() async { + closed = true; + } + + @override + Future openUrlInWebView(String url, WebViewOptions options) async { + passedWebViewOptions = options; + usedWebView = true; + return _launch(url); + } + + bool _launch(String url) { + final String scheme = url.split(':')[0]; + switch (scheme) { + case 'http': + case 'https': + return !url.contains(specialHandlerDomain); + case 'noactivity': + throw PlatformException(code: 'NO_ACTIVITY'); + default: + return false; + } + } +}