diff --git a/flutter_custom_tabs/example/android/app/build.gradle b/flutter_custom_tabs/example/android/app/build.gradle index 96ae6225..5d2961ed 100644 --- a/flutter_custom_tabs/example/android/app/build.gradle +++ b/flutter_custom_tabs/example/android/app/build.gradle @@ -1,6 +1,6 @@ plugins { - id "com.android.application" - id "dev.flutter.flutter-gradle-plugin" + id 'com.android.application' + id 'dev.flutter.flutter-gradle-plugin' } def localProperties = new Properties() @@ -22,12 +22,12 @@ if (flutterVersionName == null) { } android { - namespace "com.github.droibit.flutter.plugins.customtabs.example" + namespace 'com.github.droibit.flutter.plugins.customtabs.example' compileSdk 34 // flutter.compileSdkVersion ndkVersion flutter.ndkVersion defaultConfig { - applicationId "com.github.droibit.flutter.plugins.customtabs.example" + applicationId 'com.github.droibit.flutter.plugins.customtabs.example' // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdk flutter.minSdkVersion @@ -44,6 +44,7 @@ android { signingConfig signingConfigs.debug } } + lint { disable 'InvalidPackage' } diff --git a/flutter_custom_tabs/example/android/build.gradle b/flutter_custom_tabs/example/android/build.gradle index 14d64c67..b0e1bcc8 100644 --- a/flutter_custom_tabs/example/android/build.gradle +++ b/flutter_custom_tabs/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' + classpath 'com.android.tools.build:gradle:8.1.4' } } @@ -15,6 +15,12 @@ allprojects { mavenCentral() maven { url "https://jitpack.io" } } + + gradle.projectsEvaluated { + tasks.withType(JavaCompile).configureEach { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } + } } rootProject.buildDir = '../build' diff --git a/flutter_custom_tabs_android/android/build.gradle b/flutter_custom_tabs_android/android/build.gradle index 2f94a956..7e0fe068 100644 --- a/flutter_custom_tabs_android/android/build.gradle +++ b/flutter_custom_tabs_android/android/build.gradle @@ -1,5 +1,4 @@ group 'com.github.droibit.plugins.flutter.customtabs' -version '1.0-SNAPSHOT' buildscript { repositories { @@ -8,7 +7,8 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.4.0' + classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10' } } @@ -21,9 +21,10 @@ rootProject.allprojects { } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { - if (project.android.hasProperty("namespace")) { + if (project.android.hasProperty('namespace')) { namespace 'com.github.droibit.plugins.flutter.customtabs' } @@ -47,15 +48,30 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions { + jvmTarget = '1.8' + } + + testOptions { + unitTests.all { + testLogging { + events 'passed', 'skipped', 'failed', 'standardOut', 'standardError' + outputs.upToDateWhen { false } + showStandardStreams = true + } + } + } + dependencies { + implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.browser:browser:1.8.0' implementation 'com.github.droibit:customtabslauncher:3.0.0-beta01' testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.11' - testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation 'io.mockk:mockk:1.13.3' testImplementation 'com.google.truth:truth:1.4.4' testImplementation 'androidx.test.ext:truth:1.6.0' - testImplementation 'androidx.test.ext:junit:1.2.1' + testImplementation 'androidx.test.ext:junit-ktx:1.2.1' } } \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.java deleted file mode 100644 index 1d947597..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs; - -import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP; -import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP; -import static androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION; -import static java.util.Objects.requireNonNull; - -import android.app.Activity; -import android.app.ActivityManager; -import android.content.ActivityNotFoundException; -import android.content.ComponentName; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.core.content.ContextCompat; - -import com.github.droibit.flutter.plugins.customtabs.Messages.FlutterError; -import com.github.droibit.flutter.plugins.customtabs.core.CustomTabsIntentFactory; -import com.github.droibit.flutter.plugins.customtabs.core.ExternalBrowserLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.NativeAppLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.PartialCustomTabsLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionController; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionManager; - -import java.util.List; -import java.util.Map; -import java.util.Objects; - -@RestrictTo(RestrictTo.Scope.LIBRARY) -public class CustomTabsLauncher implements Messages.CustomTabsApi { - @VisibleForTesting - static final @NonNull String CODE_LAUNCH_ERROR = "LAUNCH_ERROR"; - - private final @NonNull CustomTabsIntentFactory customTabsIntentFactory; - private final @NonNull CustomTabsSessionManager customTabsSessionManager; - private final @NonNull NativeAppLauncher nativeAppLauncher; - private final @NonNull ExternalBrowserLauncher externalBrowserLauncher; - private final @NonNull PartialCustomTabsLauncher partialCustomTabsLauncher; - private @Nullable Activity activity; - - CustomTabsLauncher() { - this( - new CustomTabsIntentFactory(), - new CustomTabsSessionManager(), - new NativeAppLauncher(), - new ExternalBrowserLauncher(), - new PartialCustomTabsLauncher() - ); - } - - @VisibleForTesting - CustomTabsLauncher( - @NonNull CustomTabsIntentFactory customTabsIntentFactory, - @NonNull CustomTabsSessionManager customTabsSessionManager, - @NonNull NativeAppLauncher nativeAppLauncher, - @NonNull ExternalBrowserLauncher externalBrowserLauncher, - @NonNull PartialCustomTabsLauncher partialCustomTabsLauncher - ) { - this.customTabsIntentFactory = customTabsIntentFactory; - this.customTabsSessionManager = customTabsSessionManager; - this.nativeAppLauncher = nativeAppLauncher; - this.externalBrowserLauncher = externalBrowserLauncher; - this.partialCustomTabsLauncher = partialCustomTabsLauncher; - } - - void setActivity(@Nullable Activity activity) { - customTabsSessionManager.handleActivityChange(activity); - this.activity = activity; - } - - @Override - public void launch( - @NonNull String urlString, - @NonNull Boolean prefersDeepLink, - @Nullable Map options - ) { - final Activity activity = this.activity; - if (activity == null) { - throw new FlutterError(CODE_LAUNCH_ERROR, "Launching a Custom Tab requires a foreground activity.", null); - } - - final Uri uri = Uri.parse(urlString); - if (prefersDeepLink && nativeAppLauncher.launch(activity, uri)) { - return; - } - - try { - final CustomTabsIntentOptions customTabsOptions = customTabsIntentFactory - .createIntentOptions(options); - if (externalBrowserLauncher.launch(activity, uri, customTabsOptions)) { - return; - } - - final CustomTabsIntent customTabsIntent = customTabsIntentFactory.createIntent( - activity, - requireNonNull(customTabsOptions), - customTabsSessionManager - ); - if (partialCustomTabsLauncher.launch(activity, uri, customTabsIntent)) { - return; - } - customTabsIntent.launchUrl(activity, uri); - } catch (ActivityNotFoundException e) { - throw new FlutterError(CODE_LAUNCH_ERROR, e.getMessage(), null); - } - } - - @Override - public void closeAllIfPossible() { - final Activity activity = this.activity; - if (activity == null) { - return; - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return; - } - - final ActivityManager am = ContextCompat.getSystemService(activity, ActivityManager.class); - final ComponentName selfActivityName = new ComponentName(activity, activity.getClass()); - //noinspection DataFlowIssue - for (ActivityManager.AppTask appTask : am.getAppTasks()) { - final ActivityManager.RecentTaskInfo taskInfo = appTask.getTaskInfo(); - if (!Objects.equals(selfActivityName, taskInfo.baseActivity) || - taskInfo.topActivity == null) { - continue; - } - final Intent serviceIntent = new Intent(ACTION_CUSTOM_TABS_CONNECTION) - .setPackage(taskInfo.topActivity.getPackageName()); - - if (resolveService(activity.getPackageManager(), serviceIntent, 0) != null) { - try { - final Intent intent = new Intent(activity, activity.getClass()) - .setFlags(FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_SINGLE_TOP); - activity.startActivity(intent); - } catch (ActivityNotFoundException ignored) { - } - break; - } - } - } - - /** - * @noinspection SameParameterValue - */ - @SuppressWarnings("deprecation") - private static @Nullable ResolveInfo resolveService( - @NonNull PackageManager pm, - @NonNull Intent intent, - int flags - ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return pm.resolveService( - intent, - PackageManager.ResolveInfoFlags.of(flags) - ); - } else { - return pm.resolveService(intent, flags); - } - } - - @Override - public @Nullable String warmup(@Nullable Map options) { - final Activity activity = this.activity; - if (activity == null) { - return null; - } - - final CustomTabsSessionOptions sessionOptions = customTabsSessionManager - .createSessionOptions(options); - final CustomTabsSessionController sessionController = customTabsSessionManager - .createSessionController(activity, sessionOptions); - if (sessionController == null) { - return null; - } - - if (sessionController.bindCustomTabsService(activity)) { - return sessionController.getPackageName(); - } - return null; - } - - @Override - public void mayLaunch(@NonNull List urls, @NonNull String sessionPackageName) { - final CustomTabsSessionController controller = customTabsSessionManager - .getSessionController(sessionPackageName); - if (controller == null) { - return; - } - controller.mayLaunchUrls(urls); - } - - @Override - public void invalidate(@NonNull String sessionPackageName) { - customTabsSessionManager.invalidateSession(sessionPackageName); - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.java deleted file mode 100644 index 5d3340eb..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.github.droibit.flutter.plugins.customtabs.Messages.CustomTabsApi; - -import io.flutter.embedding.engine.plugins.FlutterPlugin; -import io.flutter.embedding.engine.plugins.activity.ActivityAware; -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; - -public class CustomTabsPlugin implements FlutterPlugin, ActivityAware { - private @Nullable CustomTabsLauncher api; - - @Override - public void onAttachedToEngine(@NonNull FlutterPlugin.FlutterPluginBinding binding) { - api = new CustomTabsLauncher(); - CustomTabsApi.setUp(binding.getBinaryMessenger(), api); - } - - @Override - public void onDetachedFromEngine(@NonNull FlutterPlugin.FlutterPluginBinding binding) { - if (api == null) { - return; - } - - CustomTabsApi.setUp(binding.getBinaryMessenger(), null); - api = null; - } - - @Override - public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { - if (api == null) { - return; - } - api.setActivity(binding.getActivity()); - } - - @Override - public void onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity(); - } - - @Override - public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { - onAttachedToActivity(binding); - } - - @Override - public void onDetachedFromActivity() { - if (api == null) { - return; - } - api.setActivity(null); - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/Messages.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/Messages.java deleted file mode 100644 index 85ca3f83..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/Messages.java +++ /dev/null @@ -1,229 +0,0 @@ -// Autogenerated from Pigeon (v21.1.0), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -package com.github.droibit.flutter.plugins.customtabs; - -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.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** 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; - } - - private static class PigeonCodec extends StandardMessageCodec { - public static final PigeonCodec INSTANCE = new PigeonCodec(); - - private PigeonCodec() {} - - @Override - protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { - switch (type) { - default: - return super.readValueOfType(type, buffer); - } - } - - @Override - protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { -{ - super.writeValue(stream, value); - } - } - } - - /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ - public interface CustomTabsApi { - - void launch(@NonNull String urlString, @NonNull Boolean prefersDeepLink, @Nullable Map options); - - void closeAllIfPossible(); - - @Nullable - String warmup(@Nullable Map options); - - void mayLaunch(@NonNull List urls, @NonNull String sessionPackageName); - - void invalidate(@NonNull String sessionPackageName); - - /** The codec used by CustomTabsApi. */ - static @NonNull MessageCodec getCodec() { - return PigeonCodec.INSTANCE; - } - /**Sets up an instance of `CustomTabsApi` to handle messages through the `binaryMessenger`. */ - static void setUp(@NonNull BinaryMessenger binaryMessenger, @Nullable CustomTabsApi api) { - setUp(binaryMessenger, "", api); - } - static void setUp(@NonNull BinaryMessenger binaryMessenger, @NonNull String messageChannelSuffix, @Nullable CustomTabsApi api) { - messageChannelSuffix = messageChannelSuffix.isEmpty() ? "" : "." + messageChannelSuffix; - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.launch" + messageChannelSuffix, getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - ArrayList args = (ArrayList) message; - String urlStringArg = (String) args.get(0); - Boolean prefersDeepLinkArg = (Boolean) args.get(1); - Map optionsArg = (Map) args.get(2); - try { - api.launch(urlStringArg, prefersDeepLinkArg, optionsArg); - wrapped.add(0, null); - } - catch (Throwable exception) { - ArrayList wrappedError = wrapError(exception); - wrapped = wrappedError; - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.closeAllIfPossible" + messageChannelSuffix, getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - try { - api.closeAllIfPossible(); - wrapped.add(0, null); - } - catch (Throwable exception) { - ArrayList wrappedError = wrapError(exception); - wrapped = wrappedError; - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.warmup" + messageChannelSuffix, getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - ArrayList args = (ArrayList) message; - Map optionsArg = (Map) args.get(0); - try { - String output = api.warmup(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.flutter_custom_tabs_android.CustomTabsApi.mayLaunch" + messageChannelSuffix, getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - ArrayList args = (ArrayList) message; - List urlsArg = (List) args.get(0); - String sessionPackageNameArg = (String) args.get(1); - try { - api.mayLaunch(urlsArg, sessionPackageNameArg); - wrapped.add(0, null); - } - catch (Throwable exception) { - ArrayList wrappedError = wrapError(exception); - wrapped = wrappedError; - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.invalidate" + messageChannelSuffix, getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList(); - ArrayList args = (ArrayList) message; - String sessionPackageNameArg = (String) args.get(0); - try { - api.invalidate(sessionPackageNameArg); - wrapped.add(0, null); - } - catch (Throwable exception) { - ArrayList wrappedError = wrapError(exception); - wrapped = wrappedError; - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.java deleted file mode 100644 index 95743605..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.java +++ /dev/null @@ -1,249 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK; -import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT; -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.setChromeCustomTabsPackage; -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.setCustomTabsPackage; -import static com.github.droibit.flutter.plugins.customtabs.core.ResourceFactory.INVALID_RESOURCE_ID; - -import android.content.Context; -import android.graphics.Bitmap; -import android.os.Bundle; -import android.provider.Browser; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.browser.customtabs.CustomTabsSession; - -import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider; -import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsAnimations; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsCloseButton; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsColorSchemes; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; -import com.github.droibit.flutter.plugins.customtabs.core.options.PartialCustomTabsConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionProvider; - -import java.util.Map; - -public class CustomTabsIntentFactory { - private final @NonNull ResourceFactory resources; - - public CustomTabsIntentFactory() { - this(new ResourceFactory()); - } - - @VisibleForTesting - CustomTabsIntentFactory(@NonNull ResourceFactory resources) { - this.resources = resources; - } - - public @NonNull CustomTabsIntent createIntent( - @NonNull Context context, - @NonNull CustomTabsIntentOptions options, - @NonNull CustomTabsSessionProvider sessionProvider - ) { - final BrowserConfiguration browserConfiguration; - if (options.getBrowser() != null) { - browserConfiguration = options.getBrowser(); - } else { - browserConfiguration = new BrowserConfiguration(); - } - final CustomTabsSession session = sessionProvider - .getSession(browserConfiguration.getSessionPackageName()); - - final CustomTabsIntent.Builder builder; - if (session == null) { - builder = new CustomTabsIntent.Builder(); - } else { - builder = new CustomTabsIntent.Builder(session); - } - - final CustomTabsColorSchemes colorSchemes = options.getColorSchemes(); - if (colorSchemes != null) { - applyColorSchemes(builder, colorSchemes); - } - - final CustomTabsCloseButton closeButton = options.getCloseButton(); - if (closeButton != null) { - applyCloseButton(context, builder, closeButton); - } - - final Boolean urlBarHidingEnabled = options.getUrlBarHidingEnabled(); - if (urlBarHidingEnabled != null) { - builder.setUrlBarHidingEnabled(urlBarHidingEnabled); - } - - final Integer shareState = options.getShareState(); - if (shareState != null) { - builder.setShareState(shareState); - } - - final Boolean showTitle = options.getShowTitle(); - if (showTitle != null) { - builder.setShowTitle(showTitle); - } - - final Boolean instantAppsEnabled = options.getInstantAppsEnabled(); - if (instantAppsEnabled != null) { - builder.setInstantAppsEnabled(instantAppsEnabled); - } - - final CustomTabsAnimations animations = options.getAnimations(); - if (animations != null) { - applyAnimations(context, builder, animations); - } - - final PartialCustomTabsConfiguration partial = options.getPartial(); - if (partial != null) { - applyPartialCustomTabsConfiguration(context, builder, partial); - } - - final CustomTabsIntent customTabsIntent = builder.build(); - applyBrowserConfiguration(context, customTabsIntent, browserConfiguration); - return customTabsIntent; - } - - @VisibleForTesting - void applyColorSchemes( - @NonNull CustomTabsIntent.Builder builder, - @NonNull CustomTabsColorSchemes colorSchemes - ) { - final Integer colorScheme = colorSchemes.getColorScheme(); - if (colorScheme != null) { - builder.setColorScheme(colorScheme); - } - - final CustomTabColorSchemeParams lightParams = colorSchemes.getLightParams(); - if (lightParams != null) { - builder.setColorSchemeParams(COLOR_SCHEME_LIGHT, lightParams); - } - - final CustomTabColorSchemeParams darkParams = colorSchemes.getDarkParams(); - if (darkParams != null) { - builder.setColorSchemeParams(COLOR_SCHEME_DARK, darkParams); - } - - final CustomTabColorSchemeParams defaultPrams = colorSchemes.getDefaultPrams(); - if (defaultPrams != null) { - builder.setDefaultColorSchemeParams(defaultPrams); - } - } - - @VisibleForTesting - void applyCloseButton( - @NonNull Context context, - @NonNull CustomTabsIntent.Builder builder, - @NonNull CustomTabsCloseButton closeButton - ) { - final String icon = closeButton.getIcon(); - if (icon != null) { - final Bitmap closeButtonIcon = resources.getBitmap(context, icon); - if (closeButtonIcon != null) { - builder.setCloseButtonIcon(closeButtonIcon); - } - } - - final Integer position = closeButton.getPosition(); - if (position != null) { - builder.setCloseButtonPosition(position); - } - } - - @VisibleForTesting - void applyAnimations( - @NonNull Context context, - @NonNull CustomTabsIntent.Builder builder, - @NonNull CustomTabsAnimations animations - ) { - final int startEnterAnimationId = resources.getAnimationIdentifier(context, animations.getStartEnter()); - final int startExitAnimationId = resources.getAnimationIdentifier(context, animations.getStartExit()); - final int endEnterAnimationId = resources.getAnimationIdentifier(context, animations.getEndEnter()); - final int endExitAnimationId = resources.getAnimationIdentifier(context, animations.getEndExit()); - - if (startEnterAnimationId != INVALID_RESOURCE_ID && startExitAnimationId != INVALID_RESOURCE_ID) { - builder.setStartAnimations(context, startEnterAnimationId, startExitAnimationId); - } - - if (endEnterAnimationId != INVALID_RESOURCE_ID && endExitAnimationId != INVALID_RESOURCE_ID) { - builder.setExitAnimations(context, endEnterAnimationId, endExitAnimationId); - } - } - - @VisibleForTesting - void applyPartialCustomTabsConfiguration( - @NonNull Context context, - @NonNull CustomTabsIntent.Builder builder, - @NonNull PartialCustomTabsConfiguration configuration - ) { - final Double initialHeightDp = configuration.getInitialHeight(); - final Integer resizeBehavior = configuration.getActivityHeightResizeBehavior(); - - if (initialHeightDp != null) { - final int initialHeightPx = resources.convertToPx(context, initialHeightDp); - if (resizeBehavior != null) { - builder.setInitialActivityHeightPx(initialHeightPx, resizeBehavior); - } else { - builder.setInitialActivityHeightPx(initialHeightPx); - } - } - - final Integer cornerRadius = configuration.getCornerRadius(); - if (cornerRadius != null) { - builder.setToolbarCornerRadiusDp(cornerRadius); - } - } - - @VisibleForTesting - void applyBrowserConfiguration( - @NonNull Context context, - @NonNull CustomTabsIntent customTabsIntent, - @NonNull BrowserConfiguration options - ) { - final Map headers = options.getHeaders(); - if (headers != null) { - final Bundle bundleHeaders = extractBundle(headers); - customTabsIntent.intent.putExtra(Browser.EXTRA_HEADERS, bundleHeaders); - } - - // Avoid overriding the package if using CustomTabsSession. - if (customTabsIntent.intent.getPackage() != null) { - return; - } - final String sessionPackageName = options.getSessionPackageName(); - if (sessionPackageName != null) { - // If CustomTabsSession is not obtained after service binding, - // fallback to launching the Custom Tabs resolved during warmup. - customTabsIntent.intent.setPackage(sessionPackageName); - return; - } - - final CustomTabsPackageProvider fallback = options.getAdditionalCustomTabs(context); - final Boolean prefersDefaultBrowser = options.getPrefersDefaultBrowser(); - if (prefersDefaultBrowser != null && prefersDefaultBrowser) { - setCustomTabsPackage(customTabsIntent, context, fallback); - } else { - setChromeCustomTabsPackage(customTabsIntent, context, fallback); - } - } - - private @NonNull Bundle extractBundle(@NonNull Map headers) { - final Bundle dest = new Bundle(headers.size()); - for (Map.Entry entry : headers.entrySet()) { - dest.putString(entry.getKey(), entry.getValue()); - } - return dest; - } - - public @Nullable CustomTabsIntentOptions createIntentOptions(@Nullable Map options) { - if (options == null) { - return null; - } - return new CustomTabsIntentOptions.Builder() - .setOptions(options) - .build(); - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.java deleted file mode 100644 index 56fd0a7d..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.provider.Browser; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; - -import java.util.Map; - -public class ExternalBrowserLauncher { - public ExternalBrowserLauncher() { - } - - public boolean launch( - @NonNull Context context, - @NonNull Uri uri, - @Nullable CustomTabsIntentOptions options - ) { - final Intent externalBrowserIntent = createIntent(options); - if (externalBrowserIntent == null) { - return false; - } - - externalBrowserIntent.setData(uri); - context.startActivity(externalBrowserIntent); - return true; - } - - @VisibleForTesting - @Nullable - Intent createIntent(@Nullable CustomTabsIntentOptions options) { - final Intent intent = new Intent(Intent.ACTION_VIEW); - if (options == null) { - return intent; - } - - final BrowserConfiguration browserOptions = options.getBrowser(); - if (browserOptions == null || !browserOptions.getPrefersExternalBrowser()) { - return null; - } - - final Map headers = browserOptions.getHeaders(); - if (headers != null) { - final Bundle bundleHeaders = extractBundle(headers); - intent.putExtra(Browser.EXTRA_HEADERS, bundleHeaders); - } - return intent; - } - - private @NonNull Bundle extractBundle(@NonNull Map headers) { - final Bundle dest = new Bundle(headers.size()); - for (Map.Entry entry : headers.entrySet()) { - dest.putString(entry.getKey(), entry.getValue()); - } - return dest; - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.java deleted file mode 100644 index 3176aa7e..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * ref. Let native applications handle the content - */ -public class NativeAppLauncher { - public boolean launch(@NonNull Context context, @NonNull Uri uri) { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? - launchNativeApi30(context, uri) : - launchNativeBeforeApi30(context, uri); - } - - @RequiresApi(api = Build.VERSION_CODES.R) - private boolean launchNativeApi30(@NonNull Context context, @NonNull Uri uri) { - final Intent nativeAppIntent = new Intent(Intent.ACTION_VIEW, uri) - .addCategory(Intent.CATEGORY_BROWSABLE) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER); - try { - context.startActivity(nativeAppIntent); - return true; - } catch (ActivityNotFoundException ignored) { - return false; - } - } - - private boolean launchNativeBeforeApi30(@NonNull Context context, @NonNull Uri uri) { - final PackageManager pm = context.getPackageManager(); - - // Get all Apps that resolve a generic url - final Intent browserActivityIntent = new Intent() - .setAction(Intent.ACTION_VIEW) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setData(Uri.fromParts(uri.getScheme(), "", null)); - final Set genericResolvedList = - extractPackageNames(queryIntentActivities(pm, browserActivityIntent)); - - // Get all apps that resolve the specific Url - final Intent specializedActivityIntent = new Intent(Intent.ACTION_VIEW, uri) - .addCategory(Intent.CATEGORY_BROWSABLE); - final Set resolvedSpecializedList = - extractPackageNames(queryIntentActivities(pm, specializedActivityIntent)); - - // Keep only the Urls that resolve the specific, but not the generic urls. - resolvedSpecializedList.removeAll(genericResolvedList); - // If the list is empty, no native app handlers were found. - if (resolvedSpecializedList.isEmpty()) { - return false; - } - - // We found native handlers. Launch the Intent. - specializedActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(specializedActivityIntent); - return true; - } - - @SuppressWarnings("deprecation") - private static @NonNull List queryIntentActivities( - @NonNull PackageManager pm, - @NonNull Intent intent - ) { - final int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - ? PackageManager.MATCH_ALL : PackageManager.MATCH_DEFAULT_ONLY; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return pm.queryIntentActivities( - intent, - PackageManager.ResolveInfoFlags.of(flags) - ); - } else { - return pm.queryIntentActivities(intent, flags); - } - } - - private static @NonNull Set extractPackageNames(@NonNull List resolveInfo) { - final Set packageNames = new HashSet<>(resolveInfo.size()); - for (ResolveInfo info : resolveInfo) { - packageNames.add(info.activityInfo.packageName); - } - return packageNames; - } - -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.java deleted file mode 100644 index 94060bfe..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX; - -import android.app.Activity; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.browser.customtabs.CustomTabsIntent; - -public class PartialCustomTabsLauncher { - private static final int REQUEST_CODE_PARTIAL_CUSTOM_TABS = 1001; - - public PartialCustomTabsLauncher() { - } - - public boolean launch( - @NonNull Activity activity, - @NonNull Uri uri, - @NonNull CustomTabsIntent customTabsIntent - ) { - final Intent rawIntent = customTabsIntent.intent; - if (rawIntent.hasExtra(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX) && - rawIntent.hasExtra(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR)) { - // ref. https://developer.chrome.com/docs/android/custom-tabs/guide-partial-custom-tabs - rawIntent.setData(uri); - activity.startActivityForResult(rawIntent, REQUEST_CODE_PARTIAL_CUSTOM_TABS); - return true; - } - return false; - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.java deleted file mode 100644 index 730cd3e1..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.os.Build; - -import androidx.annotation.AnimRes; -import androidx.annotation.AnyRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; - -import java.util.regex.Pattern; - -class ResourceFactory { - static final int INVALID_RESOURCE_ID = 0; - - // Note: The full resource qualifier is "package:type/entry". - // https://developer.android.com/reference/android/content/res/Resources.html#getIdentifier(java.lang.String, java.lang.String, java.lang.String) - private static final Pattern fullIdentifierPattern = Pattern.compile("^.+:.+/"); - - @Nullable - Bitmap getBitmap(@NonNull Context context, @Nullable String drawableIdentifier) { - final int drawableResId = resolveIdentifier(context, "drawable", drawableIdentifier); - if (drawableResId == INVALID_RESOURCE_ID) { - return null; - } - - Drawable drawable = ContextCompat.getDrawable(context, drawableResId); - if (drawable == null) { - return null; - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - drawable = (DrawableCompat.wrap(drawable)).mutate(); - } - if (drawable instanceof BitmapDrawable) { - return ((BitmapDrawable) drawable).getBitmap(); - } - final Bitmap bitmap = Bitmap.createBitmap( - drawable.getIntrinsicWidth(), - drawable.getIntrinsicHeight(), - Bitmap.Config.ARGB_8888 - ); - - final Rect oldBounds = drawable.getBounds(); - final Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - - drawable.setBounds(oldBounds.left, oldBounds.top, oldBounds.right, oldBounds.bottom); - return bitmap; - } - - @AnimRes - int getAnimationIdentifier(@NonNull Context context, @Nullable String identifier) { - return resolveIdentifier(context, "anim", identifier); - } - - @SuppressLint("DiscouragedApi") - @AnyRes - private int resolveIdentifier( - @NonNull Context context, - @NonNull String defType, - @Nullable String identifier - ) { - if (identifier == null) { - return INVALID_RESOURCE_ID; - } - if (fullIdentifierPattern.matcher(identifier).find()) { - return context.getResources().getIdentifier(identifier, null, null); - } else { - return context.getResources().getIdentifier(identifier, defType, context.getPackageName()); - } - } - - @Px - int convertToPx(@NonNull Context context, double dp) { - final float scale = context.getResources().getDisplayMetrics().density; - return (int) (dp * scale + 0.5); - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.java deleted file mode 100644 index aa7b24c0..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider; -import com.droibit.android.customtabs.launcher.NonChromeCustomTabs; - -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class BrowserConfiguration { - private final @Nullable Boolean prefersExternalBrowser; - private final @Nullable Boolean prefersDefaultBrowser; - private final @Nullable Set fallbackCustomTabPackages; - private final @Nullable Map headers; - private final @Nullable String sessionPackageName; - - public boolean getPrefersExternalBrowser() { - if (prefersExternalBrowser == null) { - return false; - } - return prefersExternalBrowser; - } - - public @Nullable Boolean getPrefersDefaultBrowser() { - return prefersDefaultBrowser; - } - - public @Nullable Set getFallbackCustomTabPackages() { - return fallbackCustomTabPackages; - } - - public @Nullable Map getHeaders() { - return headers; - } - - public @Nullable String getSessionPackageName() { - return sessionPackageName; - } - - public BrowserConfiguration() { - this(false, null, null, null, null); - } - - public BrowserConfiguration( - @Nullable Boolean prefersExternalBrowser, - @Nullable Boolean prefersDefaultBrowser, - @Nullable Set fallbackCustomTabPackages, - @Nullable Map headers, - @Nullable String sessionPackageName - ) { - this.prefersExternalBrowser = prefersExternalBrowser; - this.prefersDefaultBrowser = prefersDefaultBrowser; - this.fallbackCustomTabPackages = fallbackCustomTabPackages; - this.headers = headers; - this.sessionPackageName = sessionPackageName; - } - - public @NonNull CustomTabsPackageProvider getAdditionalCustomTabs(@NonNull Context context) { - final Set fallbackCustomTabs = this.fallbackCustomTabPackages; - if (fallbackCustomTabs != null) { - return new NonChromeCustomTabs(fallbackCustomTabs); - } else { - return new NonChromeCustomTabs(context); - } - } - - public static class Builder { - private static final String KEY_PREFERS_EXTERNAL_BROWSER = "prefersExternalBrowser"; - private static final String KEY_PREFERS_DEFAULT_BROWSER = "prefersDefaultBrowser"; - private static final String KEY_BROWSER_FALLBACK_CUSTOM_TABS = "fallbackCustomTabs"; - private static final String KEY_BROWSER_HEADERS = "headers"; - private static final String KEY_SESSION_PACKAGE_NAME = "sessionPackageName"; - - private @Nullable Boolean prefersExternalBrowser; - private @Nullable Boolean prefersDefaultBrowser; - private @Nullable Set fallbackCustomTabs; - private @Nullable Map headers; - private @Nullable String sessionPackageName; - - public Builder() { - } - - /** - * @noinspection DataFlowIssue - */ - @SuppressWarnings("unchecked") - public @NonNull Builder setOptions(@Nullable Map options) { - if (options == null) { - return this; - } - - if (options.containsKey(KEY_PREFERS_EXTERNAL_BROWSER)) { - prefersExternalBrowser = (Boolean) options.get(KEY_PREFERS_EXTERNAL_BROWSER); - } - if (options.containsKey(KEY_PREFERS_DEFAULT_BROWSER)) { - prefersDefaultBrowser = (Boolean) options.get(KEY_PREFERS_DEFAULT_BROWSER); - } - if (options.containsKey(KEY_BROWSER_FALLBACK_CUSTOM_TABS)) { - fallbackCustomTabs = new HashSet<>((List) options.get(KEY_BROWSER_FALLBACK_CUSTOM_TABS)); - } - if (options.containsKey(KEY_BROWSER_HEADERS)) { - headers = (Map) options.get(KEY_BROWSER_HEADERS); - } - if (options.containsKey(KEY_SESSION_PACKAGE_NAME)) { - sessionPackageName = (String) options.get(KEY_SESSION_PACKAGE_NAME); - } - return this; - } - - public @NonNull Builder setPrefersExternalBrowser(@Nullable Boolean prefersExternalBrowser) { - this.prefersExternalBrowser = prefersExternalBrowser; - return this; - } - - public @NonNull Builder setPrefersDefaultBrowser(@Nullable Boolean prefersDefaultBrowser) { - this.prefersDefaultBrowser = prefersDefaultBrowser; - return this; - } - - public @NonNull Builder setFallbackCustomTabs(@Nullable Set fallbackCustomTabs) { - this.fallbackCustomTabs = fallbackCustomTabs; - return this; - } - - public @NonNull Builder setHeaders(@Nullable Map headers) { - this.headers = headers; - return this; - } - - public @NonNull Builder setSessionPackageName(@Nullable String sessionPackageName) { - this.sessionPackageName = sessionPackageName; - return this; - } - - public @NonNull BrowserConfiguration build() { - return new BrowserConfiguration( - prefersExternalBrowser, - prefersDefaultBrowser, - fallbackCustomTabs, - headers, - sessionPackageName - ); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.java deleted file mode 100644 index ef5b5a18..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Map; - -public class CustomTabsAnimations { - private final @Nullable String startEnter; - private final @Nullable String startExit; - private final @Nullable String endEnter; - private final @Nullable String endExit; - - public @Nullable String getStartEnter() { - return startEnter; - } - - public @Nullable String getStartExit() { - return startExit; - } - - public @Nullable String getEndEnter() { - return endEnter; - } - - public @Nullable String getEndExit() { - return endExit; - } - - public CustomTabsAnimations( - @Nullable String startEnter, - @Nullable String startExit, - @Nullable String endEnter, - @Nullable String endExit - ) { - this.startEnter = startEnter; - this.startExit = startExit; - this.endEnter = endEnter; - this.endExit = endExit; - } - - public static class Builder { - private static final String KEY_START_ENTER = "startEnter"; - private static final String KEY_START_EXIT = "startExit"; - private static final String KEY_END_ENTER = "endEnter"; - private static final String KEY_END_EXIT = "endExit"; - - private @Nullable String startEnter; - private @Nullable String startExit; - private @Nullable String endEnter; - private @Nullable String endExit; - - public Builder() { - } - - public @NonNull Builder setOptions(@Nullable Map options) { - if (options == null) { - return this; - } - - if (options.containsKey(KEY_START_ENTER)) { - startEnter = (String) options.get(KEY_START_ENTER); - } - if (options.containsKey(KEY_START_EXIT)) { - startExit = (String) options.get(KEY_START_EXIT); - } - if (options.containsKey(KEY_END_ENTER)) { - endEnter = (String) options.get(KEY_END_ENTER); - } - if (options.containsKey(KEY_END_EXIT)) { - endExit = (String) options.get(KEY_END_EXIT); - } - return this; - } - - public @NonNull Builder setStartEnter(@Nullable String startEnter) { - this.startEnter = startEnter; - return this; - } - - public @NonNull Builder setStartExit(@Nullable String startExit) { - this.startExit = startExit; - return this; - } - - public @NonNull Builder setEndEnter(@Nullable String endEnter) { - this.endEnter = endEnter; - return this; - } - - public @NonNull Builder setEndExit(@Nullable String endExit) { - this.endExit = endExit; - return this; - } - - public @NonNull CustomTabsAnimations build() { - return new CustomTabsAnimations(startEnter, startExit, endEnter, endExit); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.java deleted file mode 100644 index 87aed831..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsIntent.CloseButtonPosition; - -import java.util.Map; - -public class CustomTabsCloseButton { - private final @Nullable String icon; - private final @Nullable Integer position; - - public @Nullable String getIcon() { - return icon; - } - - public @Nullable @CloseButtonPosition Integer getPosition() { - return position; - } - - public CustomTabsCloseButton(@Nullable String icon, @Nullable Integer position) { - this.icon = icon; - this.position = position; - } - - public static class Builder { - private static final String KEY_ICON = "icon"; - private static final String KEY_POSITION = "position"; - - - private @Nullable String icon; - private @Nullable Integer position; - - public Builder() { - } - - public @NonNull Builder setOptions(@NonNull Map options) { - if (options.containsKey(KEY_ICON)) { - icon = (String) options.get(KEY_ICON); - } - if (options.containsKey(KEY_POSITION)) { - position = (Integer) options.get(KEY_POSITION); - } - return this; - } - - public @NonNull Builder setIcon(@Nullable String icon) { - this.icon = icon; - return this; - } - - public @NonNull Builder setPosition(@Nullable Integer position) { - this.position = position; - return this; - } - - public @NonNull CustomTabsCloseButton build() { - return new CustomTabsCloseButton(icon, position); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.java deleted file mode 100644 index 553e1a9a..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import android.graphics.Color; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent.ColorScheme; - -import java.util.Map; - -public class CustomTabsColorSchemes { - private final @Nullable Integer colorScheme; - private final @Nullable CustomTabColorSchemeParams lightParams; - private final @Nullable CustomTabColorSchemeParams darkParams; - private final @Nullable CustomTabColorSchemeParams defaultPrams; - - public @Nullable @ColorScheme Integer getColorScheme() { - return colorScheme; - } - - public @Nullable CustomTabColorSchemeParams getLightParams() { - return lightParams; - } - - public @Nullable CustomTabColorSchemeParams getDefaultPrams() { - return defaultPrams; - } - - public @Nullable CustomTabColorSchemeParams getDarkParams() { - return darkParams; - } - - public CustomTabsColorSchemes( - @Nullable @ColorScheme Integer colorScheme, - @Nullable CustomTabColorSchemeParams lightParams, - @Nullable CustomTabColorSchemeParams darkParams, - @Nullable CustomTabColorSchemeParams defaultPrams - ) { - this.colorScheme = colorScheme; - this.lightParams = lightParams; - this.darkParams = darkParams; - this.defaultPrams = defaultPrams; - } - - public static class Builder { - private static final String KEY_COLOR_SCHEME = "colorScheme"; - private static final String KEY_LIGHT_PARAMS = "lightParams"; - private static final String KEY_DARK_PARAMS = "darkParams"; - private static final String KEY_DEFAULT_PARAMS = "defaultParams"; - private static final String KEY_TOOLBAR_COLOR = "toolbarColor"; - private static final String KEY_NAVIGATION_BAR_COLOR = "navigationBarColor"; - private static final String KEY_NAVIGATION_BAR_DIVIDER_COLOR = "navigationBarDividerColor"; - - private @Nullable Integer colorScheme; - private @Nullable CustomTabColorSchemeParams lightParams; - private @Nullable CustomTabColorSchemeParams darkParams; - private @Nullable CustomTabColorSchemeParams defaultParams; - - public Builder() { - } - - @SuppressWarnings("unchecked") - public @NonNull Builder setOptions(@NonNull Map options) { - if (options.containsKey(KEY_COLOR_SCHEME)) { - colorScheme = (Integer) options.get(KEY_COLOR_SCHEME); - } - - if (options.containsKey(KEY_LIGHT_PARAMS)) { - lightParams = buildColorSchemeParams((Map) options.get(KEY_LIGHT_PARAMS)); - } - - if (options.containsKey(KEY_DARK_PARAMS)) { - darkParams = buildColorSchemeParams((Map) options.get(KEY_DARK_PARAMS)); - } - - if (options.containsKey(KEY_DEFAULT_PARAMS)) { - defaultParams = buildColorSchemeParams((Map) options.get(KEY_DEFAULT_PARAMS)); - } - return this; - } - - public @NonNull Builder setColorScheme(@Nullable @ColorScheme Integer colorScheme) { - this.colorScheme = colorScheme; - return this; - } - - public @NonNull Builder setLightParams(@Nullable CustomTabColorSchemeParams lightParams) { - this.lightParams = lightParams; - return this; - } - - public @NonNull Builder setDarkParams(@Nullable CustomTabColorSchemeParams darkParams) { - this.darkParams = darkParams; - return this; - } - - public @NonNull Builder setDefaultParams(@Nullable CustomTabColorSchemeParams defaultParams) { - this.defaultParams = defaultParams; - return this; - } - - public @NonNull CustomTabsColorSchemes build() { - return new CustomTabsColorSchemes(colorScheme, lightParams, darkParams, defaultParams); - } - - private static @Nullable CustomTabColorSchemeParams buildColorSchemeParams(@Nullable Map source) { - if (source == null) { - return null; - } - final CustomTabColorSchemeParams.Builder builder = new CustomTabColorSchemeParams.Builder(); - final @Nullable String toolbarColor = (String) source.get(KEY_TOOLBAR_COLOR); - if (toolbarColor != null) { - builder.setToolbarColor(Color.parseColor(toolbarColor)); - } - - final @Nullable String navigationBarColor = (String) source.get(KEY_NAVIGATION_BAR_COLOR); - if (navigationBarColor != null) { - builder.setNavigationBarColor(Color.parseColor(navigationBarColor)); - } - - final @Nullable String navigationBarDividerColor = (String) source.get(KEY_NAVIGATION_BAR_DIVIDER_COLOR); - if (navigationBarDividerColor != null) { - builder.setNavigationBarDividerColor(Color.parseColor(navigationBarDividerColor)); - } - return builder.build(); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.java deleted file mode 100644 index 262f91eb..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsIntent.ShareState; - -import java.util.Map; - -public class CustomTabsIntentOptions { - private final @Nullable CustomTabsColorSchemes colorSchemes; - private final @Nullable Boolean urlBarHidingEnabled; - private final @Nullable Integer shareState; - private final @Nullable Boolean showTitle; - private final @Nullable Boolean instantAppsEnabled; - private final @Nullable CustomTabsCloseButton closeButton; - private final @Nullable CustomTabsAnimations animations; - private final @Nullable BrowserConfiguration browser; - private final @Nullable PartialCustomTabsConfiguration partial; - - public @Nullable CustomTabsColorSchemes getColorSchemes() { - return colorSchemes; - } - - public @Nullable Boolean getUrlBarHidingEnabled() { - return urlBarHidingEnabled; - } - - public @Nullable @ShareState Integer getShareState() { - return shareState; - } - - public @Nullable Boolean getShowTitle() { - return showTitle; - } - - public @Nullable Boolean getInstantAppsEnabled() { - return instantAppsEnabled; - } - - public @Nullable CustomTabsCloseButton getCloseButton() { - return closeButton; - } - - public @Nullable CustomTabsAnimations getAnimations() { - return animations; - } - - public @Nullable BrowserConfiguration getBrowser() { - return browser; - } - - public @Nullable PartialCustomTabsConfiguration getPartial() { - return partial; - } - - public CustomTabsIntentOptions( - @Nullable CustomTabsColorSchemes colorSchemes, - @Nullable Boolean urlBarHidingEnabled, - @Nullable Integer shareState, - @Nullable Boolean showTitle, - @Nullable Boolean instantAppsEnabled, - @Nullable CustomTabsCloseButton closeButton, - @Nullable CustomTabsAnimations animations, - @Nullable BrowserConfiguration browser, - @Nullable PartialCustomTabsConfiguration partial - ) { - this.colorSchemes = colorSchemes; - this.urlBarHidingEnabled = urlBarHidingEnabled; - this.shareState = shareState; - this.showTitle = showTitle; - this.instantAppsEnabled = instantAppsEnabled; - this.closeButton = closeButton; - this.animations = animations; - this.browser = browser; - this.partial = partial; - } - - public static class Builder { - private static final String KEY_COLOR_SCHEMES = "colorSchemes"; - private static final String KEY_URL_BAR_HIDING_ENABLED = "urlBarHidingEnabled"; - private static final String KEY_SHARE_STATE = "shareState"; - private static final String KEY_SHOW_TITLE = "showTitle"; - private static final String KEY_INSTANT_APPS_ENABLED = "instantAppsEnabled"; - private static final String KEY_CLOSE_BUTTON = "closeButton"; - private static final String KEY_ANIMATIONS = "animations"; - private static final String KEY_BROWSER = "browser"; - private static final String KEY_PARTIAL = "partial"; - - private @Nullable CustomTabsColorSchemes colorSchemes; - private @Nullable Boolean urlBarHidingEnabled; - private @Nullable Integer shareState; - private @Nullable Boolean showTitle; - private @Nullable Boolean instantAppsEnabled; - private @Nullable CustomTabsCloseButton closeButton; - private @Nullable CustomTabsAnimations animations; - private @Nullable BrowserConfiguration browser; - private @Nullable PartialCustomTabsConfiguration partial; - - public Builder() { - } - - /** - * @noinspection DataFlowIssue - */ - @SuppressWarnings("unchecked") - public @NonNull Builder setOptions(@NonNull Map options) { - if (options.containsKey(KEY_COLOR_SCHEMES)) { - colorSchemes = new CustomTabsColorSchemes.Builder() - .setOptions((Map) options.get(KEY_COLOR_SCHEMES)) - .build(); - } - if (options.containsKey(KEY_URL_BAR_HIDING_ENABLED)) { - urlBarHidingEnabled = (Boolean) options.get(KEY_URL_BAR_HIDING_ENABLED); - } - if (options.containsKey(KEY_SHARE_STATE)) { - shareState = (Integer) options.get(KEY_SHARE_STATE); - } - if (options.containsKey(KEY_SHOW_TITLE)) { - showTitle = (Boolean) options.get(KEY_SHOW_TITLE); - } - if (options.containsKey(KEY_INSTANT_APPS_ENABLED)) { - instantAppsEnabled = (Boolean) options.get(KEY_INSTANT_APPS_ENABLED); - } - if (options.containsKey(KEY_CLOSE_BUTTON)) { - closeButton = new CustomTabsCloseButton.Builder() - .setOptions((Map) options.get(KEY_CLOSE_BUTTON)) - .build(); - } - - if (options.containsKey(KEY_ANIMATIONS)) { - animations = new CustomTabsAnimations.Builder() - .setOptions((Map) options.get(KEY_ANIMATIONS)) - .build(); - } - if (options.containsKey(KEY_BROWSER)) { - browser = new BrowserConfiguration.Builder() - .setOptions((Map) options.get(KEY_BROWSER)) - .build(); - } - if (options.containsKey(KEY_PARTIAL)) { - partial = new PartialCustomTabsConfiguration.Builder() - .setOptions((Map) options.get(KEY_PARTIAL)) - .build(); - } - return this; - } - - public @NonNull Builder setColorSchemes(@Nullable CustomTabsColorSchemes colorSchemes) { - this.colorSchemes = colorSchemes; - return this; - } - - public @NonNull Builder setUrlBarHidingEnabled(@Nullable Boolean urlBarHidingEnabled) { - this.urlBarHidingEnabled = urlBarHidingEnabled; - return this; - } - - public @NonNull Builder setShareState(@Nullable @ShareState Integer shareState) { - this.shareState = shareState; - return this; - } - - public @NonNull Builder setShowTitle(@Nullable Boolean showTitle) { - this.showTitle = showTitle; - return this; - } - - public @NonNull Builder setInstantAppsEnabled(@Nullable Boolean instantAppsEnabled) { - this.instantAppsEnabled = instantAppsEnabled; - return this; - } - - public @NonNull Builder setCloseButton(@Nullable CustomTabsCloseButton closeButton) { - this.closeButton = closeButton; - return this; - } - - public @NonNull Builder setAnimations(@Nullable CustomTabsAnimations animations) { - this.animations = animations; - return this; - } - - public @NonNull Builder setBrowser(@Nullable BrowserConfiguration browser) { - this.browser = browser; - return this; - } - - public @NonNull Builder setPartial(@Nullable PartialCustomTabsConfiguration partial) { - this.partial = partial; - return this; - } - - public @NonNull CustomTabsIntentOptions build() { - return new CustomTabsIntentOptions( - colorSchemes, - urlBarHidingEnabled, - shareState, - showTitle, - instantAppsEnabled, - closeButton, - animations, - browser, - partial - ); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.java deleted file mode 100644 index cc6c1f65..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider; - -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class CustomTabsSessionOptions { - private final @NonNull BrowserConfiguration browser; - - public @Nullable Boolean getPrefersDefaultBrowser() { - return browser.getPrefersDefaultBrowser(); - } - - public @Nullable Set getFallbackCustomTabPackages() { - return browser.getFallbackCustomTabPackages(); - } - - public CustomTabsSessionOptions( - @Nullable Boolean prefersExternalBrowser, - @Nullable Set fallbackCustomTabPackages - ) { - browser = new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(prefersExternalBrowser) - .setFallbackCustomTabs(fallbackCustomTabPackages) - .build(); - } - - public @NonNull CustomTabsPackageProvider getAdditionalCustomTabs(@NonNull Context context) { - return browser.getAdditionalCustomTabs(context); - } - - public static class Builder { - private static final String KEY_PREFERS_DEFAULT_BROWSER = "prefersDefaultBrowser"; - private static final String KEY_BROWSER_FALLBACK_CUSTOM_TABS = "fallbackCustomTabs"; - - private @Nullable Boolean prefersExternalBrowser; - private @Nullable Set fallbackCustomTabs; - - @SuppressWarnings("unchecked") - public @NonNull Builder setOptions(@Nullable Map options) { - if (options == null) { - return this; - } - - if (options.containsKey(KEY_PREFERS_DEFAULT_BROWSER)) { - prefersExternalBrowser = (Boolean) options.get(KEY_PREFERS_DEFAULT_BROWSER); - } - if (options.containsKey(KEY_BROWSER_FALLBACK_CUSTOM_TABS)) { - fallbackCustomTabs = new HashSet<>((List) options.get(KEY_BROWSER_FALLBACK_CUSTOM_TABS)); - } - return this; - } - - public @NonNull Builder setPrefersDefaultBrowser(@Nullable Boolean prefersExternalBrowser) { - this.prefersExternalBrowser = prefersExternalBrowser; - return this; - } - - public @NonNull Builder setFallbackCustomTabs(@Nullable Set fallbackCustomTabs) { - this.fallbackCustomTabs = fallbackCustomTabs; - return this; - } - - public CustomTabsSessionOptions build() { - return new CustomTabsSessionOptions( - prefersExternalBrowser, - fallbackCustomTabs - ); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.java deleted file mode 100644 index 493b7170..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.options; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsIntent.ActivityHeightResizeBehavior; - -import java.util.Map; - -public class PartialCustomTabsConfiguration { - private final @Nullable Double initialHeight; - private final @Nullable Integer activityHeightResizeBehavior; - private final @Nullable Integer cornerRadius; - - public @Nullable Double getInitialHeight() { - return initialHeight; - } - - public @Nullable @ActivityHeightResizeBehavior Integer getActivityHeightResizeBehavior() { - return activityHeightResizeBehavior; - } - - public @Nullable Integer getCornerRadius() { - return cornerRadius; - } - - public PartialCustomTabsConfiguration( - @Nullable Double initialHeight, - @Nullable Integer activityHeightResizeBehavior, - @Nullable Integer cornerRadius - ) { - this.initialHeight = initialHeight; - this.activityHeightResizeBehavior = activityHeightResizeBehavior; - this.cornerRadius = cornerRadius; - } - - public static class Builder { - private static final String KEY_INITIAL_HEIGHT = "initialHeight"; - private static final String KEY_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR = "activityHeightResizeBehavior"; - private static final String KEY_CORNER_RADIUS = "cornerRadius"; - - private @Nullable Double initialHeight; - private @Nullable Integer activityHeightResizeBehavior; - private @Nullable Integer cornerRadius; - - public @NonNull Builder setOptions(@Nullable Map options) { - if (options == null) { - return this; - } - - if (options.containsKey(KEY_INITIAL_HEIGHT)) { - initialHeight = (Double) options.get(KEY_INITIAL_HEIGHT); - } - if (options.containsKey(KEY_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR)) { - activityHeightResizeBehavior = (Integer) options.get(KEY_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR); - } - if (options.containsKey(KEY_CORNER_RADIUS)) { - cornerRadius = (Integer) options.get(KEY_CORNER_RADIUS); - } - return this; - } - - public @NonNull Builder setInitialHeight(double initialHeight) { - this.initialHeight = initialHeight; - return this; - } - - public @NonNull Builder setActivityHeightResizeBehavior(int activityHeightResizeBehavior) { - this.activityHeightResizeBehavior = activityHeightResizeBehavior; - return this; - } - - public @NonNull Builder setCornerRadius(@Nullable Integer cornerRadius) { - this.cornerRadius = cornerRadius; - return this; - } - - public @NonNull PartialCustomTabsConfiguration build() { - return new PartialCustomTabsConfiguration( - initialHeight, - activityHeightResizeBehavior, - cornerRadius - ); - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.java deleted file mode 100644 index a68a41d7..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.session; - -import android.content.ComponentName; -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabsClient; -import androidx.browser.customtabs.CustomTabsService; -import androidx.browser.customtabs.CustomTabsServiceConnection; -import androidx.browser.customtabs.CustomTabsSession; - -import java.util.ArrayList; -import java.util.List; - -import io.flutter.Log; - -public class CustomTabsSessionController extends CustomTabsServiceConnection { - private static final @NonNull String TAG = "CustomTabsAndroid"; - - private final String packageName; - private @Nullable Context context; - private @Nullable CustomTabsSession session; - private boolean customTabsServiceBound; - - public @NonNull String getPackageName() { - return packageName; - } - - public @Nullable CustomTabsSession getSession() { - return session; - } - - @VisibleForTesting - void setSession(@Nullable CustomTabsSession session) { - this.session = session; - } - - @VisibleForTesting - boolean isCustomTabsServiceBound() { - return customTabsServiceBound; - } - - public CustomTabsSessionController(@NonNull String packageName) { - this.packageName = packageName; - } - - public boolean bindCustomTabsService(@NonNull Context context) { - if (customTabsServiceBound) { - Log.d(TAG, "Custom Tab(" + packageName + ") already bound."); - return true; - } - return tryBindCustomTabsService(context); - } - - private boolean tryBindCustomTabsService(@NonNull Context context) { - try { - final boolean bound = CustomTabsClient.bindCustomTabsService(context, packageName, this); - Log.d(TAG, "Custom Tab(" + packageName + ") bound: " + bound); - if (bound) { - this.context = context; - } - customTabsServiceBound = bound; - } catch (SecurityException e) { - customTabsServiceBound = false; - } - return customTabsServiceBound; - } - - public void unbindCustomTabsService() { - final Context context = this.context; - if (context != null) { - context.unbindService(this); - } - session = null; - customTabsServiceBound = false; - Log.d(TAG, "Custom Tab(" + packageName + ") unbound."); - } - - @Override - public void onCustomTabsServiceConnected(@NonNull ComponentName name, @NonNull CustomTabsClient client) { - final boolean warmedUp = client.warmup(0); - Log.d(TAG, "Custom Tab(" + name.getPackageName() + ") warmedUp: " + warmedUp); - session = client.newSession(null); - } - - @Override - public void onServiceDisconnected(ComponentName name) { - session = null; - customTabsServiceBound = false; - - Log.d(TAG, "Custom Tab(" + packageName + ") disconnected."); - } - - public void mayLaunchUrls(@NonNull List urls) { - final CustomTabsSession session = this.session; - if (session == null) { - Log.w(TAG, "Custom Tab session is null. Cannot may launch URLs."); - return; - } - if (urls.isEmpty()) { - Log.w(TAG, "URLs is empty. Cannot may launch URLs."); - return; - } - - if (urls.size() == 1) { - final boolean succeeded = session.mayLaunchUrl(Uri.parse(urls.get(0)), null, null); - Log.d(TAG, "May launch URL: " + succeeded); - return; - } - - final List bundles = new ArrayList<>(urls.size()); - for (String url : urls) { - final Bundle bundle = new Bundle(1); - bundle.putParcelable(CustomTabsService.KEY_URL, Uri.parse(url)); - bundles.add(bundle); - } - final boolean succeeded = session.mayLaunchUrl(null, null, bundles); - Log.d(TAG, "May launch URL(s): " + succeeded); - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.java deleted file mode 100644 index 46441c19..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.session; - -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.getCustomTabsPackage; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabsSession; - -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions; - -import java.util.HashMap; -import java.util.Map; - -public class CustomTabsSessionManager implements CustomTabsSessionProvider { - private static final @NonNull String TAG = "CustomTabsAndroid"; - - private final @NonNull Map cachedSessions; - - public CustomTabsSessionManager() { - this(new HashMap<>()); - } - - @VisibleForTesting - CustomTabsSessionManager(@NonNull Map cachedSessions) { - this.cachedSessions = cachedSessions; - } - - public @NonNull CustomTabsSessionOptions createSessionOptions(@Nullable Map options) { - return new CustomTabsSessionOptions.Builder() - .setOptions(options) - .build(); - } - - public @Nullable CustomTabsSessionController createSessionController( - @NonNull Context context, - @NonNull CustomTabsSessionOptions options - ) { - final Boolean prefersDefaultBrowser = options.getPrefersDefaultBrowser(); - final String customTabsPackage = getCustomTabsPackage( - context, - prefersDefaultBrowser != null && !prefersDefaultBrowser, - options.getAdditionalCustomTabs(context) - ); - if (customTabsPackage == null) { - return null; - } - - final CustomTabsSessionController cachedController = cachedSessions.get(customTabsPackage); - if (cachedController != null) { - return cachedController; - } - - final CustomTabsSessionController newController = new CustomTabsSessionController(customTabsPackage); - cachedSessions.put(customTabsPackage, newController); - return newController; - } - - public @Nullable CustomTabsSessionController getSessionController(@NonNull String packageName) { - return cachedSessions.get(packageName); - } - - @Override - public @Nullable CustomTabsSession getSession(@Nullable String packageName) { - if (packageName == null) { - return null; - } - - final CustomTabsSessionController controller = cachedSessions.get(packageName); - if (controller == null) { - return null; - } - return controller.getSession(); - } - - public void invalidateSession(@NonNull String packageName) { - final CustomTabsSessionController controller = cachedSessions.get(packageName); - if (controller == null) { - return; - } - controller.unbindCustomTabsService(); - cachedSessions.remove(packageName); - } - - public void handleActivityChange(@Nullable Context activity) { - Log.d(TAG, "handleActivityChange: " + activity); - for (CustomTabsSessionController controller : cachedSessions.values()) { - if (activity == null) { - controller.unbindCustomTabsService(); - } else { - controller.bindCustomTabsService(activity); - } - } - } -} diff --git a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.java b/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.java deleted file mode 100644 index b076fe07..00000000 --- a/flutter_custom_tabs_android/android/src/main/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.session; - -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsSession; - -public interface CustomTabsSessionProvider { - @Nullable - CustomTabsSession getSession(@Nullable String packageName); -} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.kt new file mode 100644 index 00000000..ce5af44b --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncher.kt @@ -0,0 +1,156 @@ +package com.github.droibit.flutter.plugins.customtabs + +import android.app.Activity +import android.app.ActivityManager +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build +import androidx.annotation.RestrictTo +import androidx.annotation.VisibleForTesting +import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import com.github.droibit.flutter.plugins.customtabs.core.CustomTabsIntentFactory +import com.github.droibit.flutter.plugins.customtabs.core.ExternalBrowserLauncher +import com.github.droibit.flutter.plugins.customtabs.core.NativeAppLauncher +import com.github.droibit.flutter.plugins.customtabs.core.PartialCustomTabsLauncher +import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionManager +import com.github.droibit.flutter.plugins.customtabs.core.utils.CODE_LAUNCH_ERROR + +@RestrictTo(RestrictTo.Scope.LIBRARY) +internal class CustomTabsLauncher @VisibleForTesting constructor( + private val customTabsIntentFactory: CustomTabsIntentFactory, + private val customTabsSessionManager: CustomTabsSessionManager, + private val nativeAppLauncher: NativeAppLauncher, + private val externalBrowserLauncher: ExternalBrowserLauncher, + private val partialCustomTabsLauncher: PartialCustomTabsLauncher +) : CustomTabsApi { + private var activity: Activity? = null + + constructor() : this( + CustomTabsIntentFactory(), + CustomTabsSessionManager(), + NativeAppLauncher(), + ExternalBrowserLauncher(), + PartialCustomTabsLauncher() + ) + + fun setActivity(activity: Activity?) { + customTabsSessionManager.handleActivityChange(activity) + this.activity = activity + } + + override fun launch( + urlString: String, + prefersDeepLink: Boolean, + options: Map? + ) { + val activity = this.activity + ?: throw FlutterError( + CODE_LAUNCH_ERROR, + "Launching a Custom Tab requires a foreground activity.", + null + ) + + val uri = urlString.toUri() + if (prefersDeepLink && nativeAppLauncher.launch(activity, uri)) { + return + } + + try { + val customTabsOptions = customTabsIntentFactory.createIntentOptions(options) + if (externalBrowserLauncher.launch(activity, uri, customTabsOptions)) { + return + } + + val customTabsIntent = customTabsIntentFactory.createIntent( + activity, + requireNotNull(customTabsOptions), + customTabsSessionManager + ) + if (partialCustomTabsLauncher.launch(activity, uri, customTabsIntent)) { + return + } + customTabsIntent.launchUrl(activity, uri) + } catch (e: ActivityNotFoundException) { + throw FlutterError(CODE_LAUNCH_ERROR, e.message, null) + } + } + + override fun closeAllIfPossible() { + val activity = this.activity ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return + } + + val am = activity.getSystemService() + val selfActivityName = ComponentName(activity, activity.javaClass) + for (appTask in requireNotNull(am).appTasks) { + val taskInfo = appTask.taskInfo + if (selfActivityName != taskInfo.baseActivity || taskInfo.topActivity == null) { + continue + } + + val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION) + .setPackage(taskInfo.topActivity?.packageName) + if (resolveService(activity.packageManager, serviceIntent) != null) { + try { + val intent = Intent(activity, activity.javaClass) + .setFlags(FLAG_ACTIVITY_CLEAR_TOP or FLAG_ACTIVITY_SINGLE_TOP) + activity.startActivity(intent) + } catch (ignored: ActivityNotFoundException) { + } + break + } + } + } + + override fun warmup(options: Map?): String? { + val activity = this.activity ?: return null + + val sessionOptions = customTabsSessionManager.createSessionOptions(options) + val sessionController = + customTabsSessionManager.createSessionController(activity, sessionOptions) + ?: return null + + return if (sessionController.bindCustomTabsService(activity)) { + sessionController.packageName + } else { + null + } + } + + override fun mayLaunch(urls: List, sessionPackageName: String) { + val controller = customTabsSessionManager.getSessionController(sessionPackageName) ?: return + @Suppress("UNCHECKED_CAST") + controller.mayLaunchUrls(urls as List) + } + + override fun invalidate(sessionPackageName: String) { + customTabsSessionManager.invalidateSession(sessionPackageName) + } + + /** + * @noinspection SameParameterValue + */ + @Suppress("deprecation") + private fun resolveService( + pm: PackageManager, + intent: Intent, + flags: Int = 0 + ): ResolveInfo? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.resolveService( + intent, + PackageManager.ResolveInfoFlags.of(flags.toLong()) + ) + } else { + pm.resolveService(intent, flags) + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.kt new file mode 100644 index 00000000..1c7dabf0 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsPlugin.kt @@ -0,0 +1,42 @@ +package com.github.droibit.flutter.plugins.customtabs + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class CustomTabsPlugin : FlutterPlugin, ActivityAware { + private var api: CustomTabsLauncher? = null + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + api = CustomTabsLauncher() + CustomTabsApi.setUp(binding.binaryMessenger, api) + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + if (api == null) { + return + } + + CustomTabsApi.setUp(binding.binaryMessenger, null) + api = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + val api = this.api ?: return + api.setActivity(binding.activity) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + val api = this.api ?: return + api.setActivity(null) + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/Messages.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/Messages.kt new file mode 100644 index 00000000..73936be9 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/Messages.kt @@ -0,0 +1,164 @@ +// Autogenerated from Pigeon (v21.1.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.github.droibit.flutter.plugins.customtabs + +import android.util.Log +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 + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() +private object MessagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface CustomTabsApi { + fun launch(urlString: String, prefersDeepLink: Boolean, options: Map?) + fun closeAllIfPossible() + fun warmup(options: Map?): String? + fun mayLaunch(urls: List, sessionPackageName: String) + fun invalidate(sessionPackageName: String) + + companion object { + /** The codec used by CustomTabsApi. */ + val codec: MessageCodec by lazy { + MessagesPigeonCodec + } + /** Sets up an instance of `CustomTabsApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: CustomTabsApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.launch$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlStringArg = args[0] as String + val prefersDeepLinkArg = args[1] as Boolean + val optionsArg = args[2] as Map? + val wrapped: List = try { + api.launch(urlStringArg, prefersDeepLinkArg, optionsArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.closeAllIfPossible$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.closeAllIfPossible() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.warmup$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsArg = args[0] as Map? + val wrapped: List = try { + listOf(api.warmup(optionsArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.mayLaunch$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlsArg = args[0] as List + val sessionPackageNameArg = args[1] as String + val wrapped: List = try { + api.mayLaunch(urlsArg, sessionPackageNameArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_custom_tabs_android.CustomTabsApi.invalidate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val sessionPackageNameArg = args[0] as String + val wrapped: List = try { + api.invalidate(sessionPackageNameArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.kt new file mode 100644 index 00000000..d2ad39f1 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactory.kt @@ -0,0 +1,155 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.content.Context +import android.provider.Browser.EXTRA_HEADERS +import androidx.annotation.VisibleForTesting +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK +import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT +import androidx.core.content.res.ResourcesCompat.ID_NULL +import com.droibit.android.customtabs.launcher.setChromeCustomTabsPackage +import com.droibit.android.customtabs.launcher.setCustomTabsPackage +import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsAnimations +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsCloseButton +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsColorSchemes +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions +import com.github.droibit.flutter.plugins.customtabs.core.options.PartialCustomTabsConfiguration +import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionProvider +import com.github.droibit.flutter.plugins.customtabs.core.utils.extractBundle + +class CustomTabsIntentFactory @VisibleForTesting internal constructor( + private val resources: ResourceFactory +) { + constructor() : this(ResourceFactory()) + + fun createIntentOptions(options: Map?): CustomTabsIntentOptions? { + if (options == null) { + return null + } + return CustomTabsIntentOptions.Builder() + .setOptions(options) + .build() + } + + fun createIntent( + context: Context, + options: CustomTabsIntentOptions, + sessionProvider: CustomTabsSessionProvider + ): CustomTabsIntent { + val browserConfiguration = options.browser ?: BrowserConfiguration.Builder().build() + val session = sessionProvider.getSession(browserConfiguration.sessionPackageName) + val builder = CustomTabsIntent.Builder(session) + + options.colorSchemes?.let { applyColorSchemes(builder, it) } + options.closeButton?.let { applyCloseButton(context, builder, it) } + options.urlBarHidingEnabled?.let { builder.setUrlBarHidingEnabled(it) } + options.shareState?.let { builder.setShareState(it) } + options.showTitle?.let { builder.setShowTitle(it) } + options.instantAppsEnabled?.let { builder.setInstantAppsEnabled(it) } + options.animations?.let { applyAnimations(context, builder, it) } + options.partial?.let { applyPartialCustomTabsConfiguration(context, builder, it) } + + return builder.build().apply { + applyBrowserConfiguration(context, this, browserConfiguration) + } + } + + @VisibleForTesting + internal fun applyColorSchemes( + builder: CustomTabsIntent.Builder, + colorSchemes: CustomTabsColorSchemes + ) { + colorSchemes.colorScheme?.let { builder.setColorScheme(it) } + colorSchemes.lightParams?.let { builder.setColorSchemeParams(COLOR_SCHEME_LIGHT, it) } + colorSchemes.darkParams?.let { builder.setColorSchemeParams(COLOR_SCHEME_DARK, it) } + colorSchemes.defaultPrams?.let { builder.setDefaultColorSchemeParams(it) } + } + + @VisibleForTesting + internal fun applyCloseButton( + context: Context, + builder: CustomTabsIntent.Builder, + closeButton: CustomTabsCloseButton + ) { + val icon = closeButton.icon + if (icon != null) { + val closeButtonIcon = resources.getBitmap(context, icon) + if (closeButtonIcon != null) { + builder.setCloseButtonIcon(closeButtonIcon) + } + } + + val position = closeButton.position + if (position != null) { + builder.setCloseButtonPosition(position) + } + } + + @VisibleForTesting + internal fun applyAnimations( + context: Context, + builder: CustomTabsIntent.Builder, + animations: CustomTabsAnimations + ) { + val startEnterAnimationId = resources.getAnimationIdentifier(context, animations.startEnter) + val startExitAnimationId = resources.getAnimationIdentifier(context, animations.startExit) + if (startEnterAnimationId != ID_NULL && startExitAnimationId != ID_NULL) { + builder.setStartAnimations(context, startEnterAnimationId, startExitAnimationId) + } + + val endEnterAnimationId = resources.getAnimationIdentifier(context, animations.endEnter) + val endExitAnimationId = resources.getAnimationIdentifier(context, animations.endExit) + if (endEnterAnimationId != ID_NULL && endExitAnimationId != ID_NULL) { + builder.setExitAnimations(context, endEnterAnimationId, endExitAnimationId) + } + } + + @VisibleForTesting + internal fun applyPartialCustomTabsConfiguration( + context: Context, + builder: CustomTabsIntent.Builder, + configuration: PartialCustomTabsConfiguration + ) { + configuration.initialHeight?.let { initialHeightDp -> + val initialHeightPx = resources.convertToPx(context, initialHeightDp) + val resizeBehavior = configuration.activityHeightResizeBehavior + if (resizeBehavior == null) { + builder.setInitialActivityHeightPx(initialHeightPx) + } else { + builder.setInitialActivityHeightPx(initialHeightPx, resizeBehavior) + } + } + configuration.cornerRadius?.let { builder.setToolbarCornerRadiusDp(it) } + } + + @VisibleForTesting + internal fun applyBrowserConfiguration( + context: Context, + customTabsIntent: CustomTabsIntent, + options: BrowserConfiguration + ) { + val rawIntent = customTabsIntent.intent + options.headers?.let { rawIntent.putExtra(EXTRA_HEADERS, extractBundle(it)) } + + // Avoid overriding the package if using CustomTabsSession. + if (rawIntent.getPackage() != null) { + return + } + val sessionPackageName = options.sessionPackageName + if (sessionPackageName != null) { + // If CustomTabsSession is not obtained after service binding, + // fallback to launching the Custom Tabs resolved during warmup. + rawIntent.setPackage(sessionPackageName) + return + } + + val fallback = options.getAdditionalCustomTabs(context) + val prefersDefaultBrowser = options.prefersDefaultBrowser + if (prefersDefaultBrowser == true) { + customTabsIntent.setCustomTabsPackage(context, fallback) + } else { + customTabsIntent.setChromeCustomTabsPackage(context, fallback) + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.kt new file mode 100644 index 00000000..cdb22c3f --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncher.kt @@ -0,0 +1,34 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Browser.EXTRA_HEADERS +import androidx.annotation.VisibleForTesting +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions +import com.github.droibit.flutter.plugins.customtabs.core.utils.extractBundle + +class ExternalBrowserLauncher { + fun launch(context: Context, uri: Uri, options: CustomTabsIntentOptions?): Boolean { + val externalBrowserIntent = createIntent(options) ?: return false + externalBrowserIntent.setData(uri) + context.startActivity(externalBrowserIntent) + return true + } + + @VisibleForTesting + internal fun createIntent(options: CustomTabsIntentOptions?): Intent? { + val intent = Intent(Intent.ACTION_VIEW) + if (options == null) { + return intent + } + + val browserOptions = options.browser ?: return null + val prefersExternalBrowser = browserOptions.prefersExternalBrowser + if (prefersExternalBrowser == true) { + browserOptions.headers?.let { intent.putExtra(EXTRA_HEADERS, extractBundle(it)) } + return intent + } + return null + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.kt new file mode 100644 index 00000000..81b6a0ad --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/NativeAppLauncher.kt @@ -0,0 +1,95 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * ref. [Let native applications handle the content](https://developer.chrome.com/docs/android/custom-tabs/howto-custom-tab-native-apps/) + */ +class NativeAppLauncher { + fun launch(context: Context, uri: Uri): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + launchNativeApi30(context, uri) + } else { + launchNativeBeforeApi30(context, uri) + } + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private fun launchNativeApi30(context: Context, uri: Uri): Boolean { + val nativeAppIntent = Intent(Intent.ACTION_VIEW, uri) + .addCategory(Intent.CATEGORY_BROWSABLE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER) + try { + context.startActivity(nativeAppIntent) + return true + } catch (ignored: ActivityNotFoundException) { + return false + } + } + + private fun launchNativeBeforeApi30(context: Context, uri: Uri): Boolean { + val pm = context.packageManager + + // Get all Apps that resolve a generic url + val browserActivityIntent = Intent() + .setAction(Intent.ACTION_VIEW) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setData(Uri.fromParts(uri.scheme, "", null)) + val genericResolvedList: Set = extractPackageNames( + queryIntentActivities(pm, browserActivityIntent) + ) + + // Get all apps that resolve the specific Url + val specializedActivityIntent = Intent(Intent.ACTION_VIEW, uri) + .addCategory(Intent.CATEGORY_BROWSABLE) + val resolvedSpecializedList = buildSet { + addAll( + extractPackageNames(queryIntentActivities(pm, specializedActivityIntent)) + ) + // Keep only the Urls that resolve the specific, but not the generic urls. + removeAll(genericResolvedList) + } + // If the list is empty, no native app handlers were found. + if (resolvedSpecializedList.isEmpty()) { + return false + } + + // We found native handlers. Launch the Intent. + specializedActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(specializedActivityIntent) + return true + } + + @Suppress("deprecation") + private fun queryIntentActivities(pm: PackageManager, intent: Intent): List { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PackageManager.MATCH_ALL + } else { + PackageManager.MATCH_DEFAULT_ONLY + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.queryIntentActivities( + intent, + PackageManager.ResolveInfoFlags.of(flags.toLong()) + ) + } else { + pm.queryIntentActivities(intent, flags) + } + } + + private fun extractPackageNames(resolveInfo: List): Set { + return buildSet(resolveInfo.size) { + for (info in resolveInfo) { + add(info.activityInfo.packageName) + } + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.kt new file mode 100644 index 00000000..db422fc1 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncher.kt @@ -0,0 +1,23 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.app.Activity +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX +import com.github.droibit.flutter.plugins.customtabs.core.utils.REQUEST_CODE_PARTIAL_CUSTOM_TABS + +class PartialCustomTabsLauncher { + fun launch(activity: Activity, uri: Uri, customTabsIntent: CustomTabsIntent): Boolean { + val rawIntent = customTabsIntent.intent + if (rawIntent.hasExtra(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX) && + rawIntent.hasExtra(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR) + ) { + // ref. https://developer.chrome.com/docs/android/custom-tabs/guide-partial-custom-tabs + rawIntent.setData(uri) + activity.startActivityForResult(rawIntent, REQUEST_CODE_PARTIAL_CUSTOM_TABS) + return true + } + return false + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.kt new file mode 100644 index 00000000..1e4284d8 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ResourceFactory.kt @@ -0,0 +1,57 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.AnimRes +import androidx.annotation.AnyRes +import androidx.annotation.Px +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.toBitmap + +class ResourceFactory { + fun getBitmap(context: Context, drawableIdentifier: String?): Bitmap? { + val drawableResId = resolveIdentifier(context, "drawable", drawableIdentifier) + if (drawableResId == ResourcesCompat.ID_NULL) { + return null + } + + var drawable = ContextCompat.getDrawable(context, drawableResId) + ?: return null + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + drawable = DrawableCompat.wrap(drawable).mutate() + } + return drawable.toBitmap() + } + + @AnimRes + fun getAnimationIdentifier(context: Context, identifier: String?): Int { + return resolveIdentifier(context, "anim", identifier) + } + + @SuppressLint("DiscouragedApi") + @AnyRes + private fun resolveIdentifier(context: Context, defType: String, name: String?): Int { + val res = context.resources + return when { + name == null -> ResourcesCompat.ID_NULL + fullIdentifierRegex.containsMatchIn(name) -> res.getIdentifier(name, null, null) + else -> res.getIdentifier(name, defType, context.packageName) + } + } + + @Px + fun convertToPx(context: Context, dp: Double): Int { + val scale = context.resources.displayMetrics.density + return (dp * scale + 0.5).toInt() + } + + private companion object { + // Note: The full resource qualifier is "package:type/entry". + // https://developer.android.com/reference/android/content/res/Resources.html#getIdentifier(java.lang.String, java.lang.String, java.lang.String) + private val fullIdentifierRegex = "^.+:.+/".toRegex() + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.kt new file mode 100644 index 00000000..674e600c --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/BrowserConfiguration.kt @@ -0,0 +1,84 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import android.content.Context +import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider +import com.droibit.android.customtabs.launcher.NonChromeCustomTabs + +class BrowserConfiguration internal constructor( + val prefersExternalBrowser: Boolean?, + val prefersDefaultBrowser: Boolean?, + val fallbackCustomTabPackages: Set?, + val headers: Map?, + val sessionPackageName: String? +) { + fun getAdditionalCustomTabs(context: Context): CustomTabsPackageProvider { + return fallbackCustomTabPackages?.let { NonChromeCustomTabs(it) } + ?: NonChromeCustomTabs(context) + } + + class Builder { + private var prefersExternalBrowser: Boolean? = null + private var prefersDefaultBrowser: Boolean? = null + private var fallbackCustomTabs: Set? = null + private var headers: Map? = null + private var sessionPackageName: String? = null + + /** + * @noinspection DataFlowIssue + */ + @Suppress("UNCHECKED_CAST") + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + + prefersExternalBrowser = options[KEY_PREFERS_EXTERNAL_BROWSER] as Boolean? + prefersDefaultBrowser = options[KEY_PREFERS_DEFAULT_BROWSER] as Boolean? + fallbackCustomTabs = (options[KEY_FALLBACK_CUSTOM_TABS] as List?)?.toSet() + headers = options[KEY_HEADERS] as Map? + sessionPackageName = options[KEY_SESSION_PACKAGE_NAME] as String? + return this + } + + fun setPrefersExternalBrowser(prefersExternalBrowser: Boolean?): Builder { + this.prefersExternalBrowser = prefersExternalBrowser + return this + } + + fun setPrefersDefaultBrowser(prefersDefaultBrowser: Boolean?): Builder { + this.prefersDefaultBrowser = prefersDefaultBrowser + return this + } + + fun setFallbackCustomTabs(fallbackCustomTabs: Set?): Builder { + this.fallbackCustomTabs = fallbackCustomTabs + return this + } + + fun setHeaders(headers: Map?): Builder { + this.headers = headers + return this + } + + fun setSessionPackageName(sessionPackageName: String?): Builder { + this.sessionPackageName = sessionPackageName + return this + } + + fun build() = BrowserConfiguration( + prefersExternalBrowser, + prefersDefaultBrowser, + fallbackCustomTabs, + headers, + sessionPackageName + ) + + private companion object { + private const val KEY_PREFERS_EXTERNAL_BROWSER = "prefersExternalBrowser" + private const val KEY_PREFERS_DEFAULT_BROWSER = "prefersDefaultBrowser" + private const val KEY_FALLBACK_CUSTOM_TABS = "fallbackCustomTabs" + private const val KEY_HEADERS = "headers" + private const val KEY_SESSION_PACKAGE_NAME = "sessionPackageName" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.kt new file mode 100644 index 00000000..82599fb8 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsAnimations.kt @@ -0,0 +1,56 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +class CustomTabsAnimations private constructor( + val startEnter: String?, + val startExit: String?, + val endEnter: String?, + val endExit: String? +) { + class Builder { + private var startEnter: String? = null + private var startExit: String? = null + private var endEnter: String? = null + private var endExit: String? = null + + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + + startEnter = options[KEY_START_ENTER] as String? + startExit = options[KEY_START_EXIT] as String? + endEnter = options[KEY_END_ENTER] as String? + endExit = options[KEY_END_EXIT] as String? + return this + } + + fun setStartEnter(startEnter: String?): Builder { + this.startEnter = startEnter + return this + } + + fun setStartExit(startExit: String?): Builder { + this.startExit = startExit + return this + } + + fun setEndEnter(endEnter: String?): Builder { + this.endEnter = endEnter + return this + } + + fun setEndExit(endExit: String?): Builder { + this.endExit = endExit + return this + } + + fun build() = CustomTabsAnimations(startEnter, startExit, endEnter, endExit) + + private companion object { + private const val KEY_START_ENTER = "startEnter" + private const val KEY_START_EXIT = "startExit" + private const val KEY_END_ENTER = "endEnter" + private const val KEY_END_EXIT = "endExit" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.kt new file mode 100644 index 00000000..00143ada --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsCloseButton.kt @@ -0,0 +1,39 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import androidx.browser.customtabs.CustomTabsIntent.CloseButtonPosition + +class CustomTabsCloseButton( + val icon: String?, + @CloseButtonPosition val position: Int? +) { + class Builder { + private var icon: String? = null + private var position: Int? = null + + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + icon = options[KEY_ICON] as String? + position = options[KEY_POSITION] as Int? + return this + } + + fun setIcon(icon: String?): Builder { + this.icon = icon + return this + } + + fun setPosition(position: Int?): Builder { + this.position = position + return this + } + + fun build() = CustomTabsCloseButton(icon, position) + + private companion object { + private const val KEY_ICON = "icon" + private const val KEY_POSITION = "position" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.kt new file mode 100644 index 00000000..b7913ac0 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsColorSchemes.kt @@ -0,0 +1,86 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import android.graphics.Color +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent.ColorScheme + +class CustomTabsColorSchemes( + @ColorScheme val colorScheme: Int?, + val lightParams: CustomTabColorSchemeParams?, + val darkParams: CustomTabColorSchemeParams?, + val defaultPrams: CustomTabColorSchemeParams? +) { + class Builder { + private var colorScheme: Int? = null + private var lightParams: CustomTabColorSchemeParams? = null + private var darkParams: CustomTabColorSchemeParams? = null + private var defaultParams: CustomTabColorSchemeParams? = null + + @Suppress("UNCHECKED_CAST") + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + colorScheme = options[KEY_COLOR_SCHEME] as Int? + lightParams = buildColorSchemeParams(options[KEY_LIGHT_PARAMS] as Map?) + darkParams = buildColorSchemeParams(options[KEY_DARK_PARAMS] as Map?) + defaultParams = buildColorSchemeParams(options[KEY_DEFAULT_PARAMS] as Map?) + return this + } + + fun setColorScheme(@ColorScheme colorScheme: Int?): Builder { + this.colorScheme = colorScheme + return this + } + + fun setLightParams(lightParams: CustomTabColorSchemeParams?): Builder { + this.lightParams = lightParams + return this + } + + fun setDarkParams(darkParams: CustomTabColorSchemeParams?): Builder { + this.darkParams = darkParams + return this + } + + fun setDefaultParams(defaultParams: CustomTabColorSchemeParams?): Builder { + this.defaultParams = defaultParams + return this + } + + fun build() = CustomTabsColorSchemes(colorScheme, lightParams, darkParams, defaultParams) + + private fun buildColorSchemeParams(source: Map?): CustomTabColorSchemeParams? { + if (source == null) { + return null + } + + val builder = CustomTabColorSchemeParams.Builder() + val toolbarColor = source[KEY_TOOLBAR_COLOR] as String? + if (toolbarColor != null) { + builder.setToolbarColor(Color.parseColor(toolbarColor)) + } + + val navigationBarColor = source[KEY_NAVIGATION_BAR_COLOR] as String? + if (navigationBarColor != null) { + builder.setNavigationBarColor(Color.parseColor(navigationBarColor)) + } + + val navigationBarDividerColor = source[KEY_NAVIGATION_BAR_DIVIDER_COLOR] as String? + if (navigationBarDividerColor != null) { + builder.setNavigationBarDividerColor(Color.parseColor(navigationBarDividerColor)) + } + return builder.build() + } + + private companion object { + private const val KEY_COLOR_SCHEME = "colorScheme" + private const val KEY_LIGHT_PARAMS = "lightParams" + private const val KEY_DARK_PARAMS = "darkParams" + private const val KEY_DEFAULT_PARAMS = "defaultParams" + private const val KEY_TOOLBAR_COLOR = "toolbarColor" + private const val KEY_NAVIGATION_BAR_COLOR = "navigationBarColor" + private const val KEY_NAVIGATION_BAR_DIVIDER_COLOR = "navigationBarDividerColor" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.kt new file mode 100644 index 00000000..e97b9b9e --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsIntentOptions.kt @@ -0,0 +1,127 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import androidx.browser.customtabs.CustomTabsIntent.ShareState + +class CustomTabsIntentOptions private constructor( + val colorSchemes: CustomTabsColorSchemes?, + val urlBarHidingEnabled: Boolean?, + @ShareState val shareState: Int?, + val showTitle: Boolean?, + val instantAppsEnabled: Boolean?, + val closeButton: CustomTabsCloseButton?, + val animations: CustomTabsAnimations?, + val browser: BrowserConfiguration?, + val partial: PartialCustomTabsConfiguration? +) { + class Builder { + private var colorSchemes: CustomTabsColorSchemes? = null + private var urlBarHidingEnabled: Boolean? = null + private var shareState: Int? = null + private var showTitle: Boolean? = null + private var instantAppsEnabled: Boolean? = null + private var closeButton: CustomTabsCloseButton? = null + private var animations: CustomTabsAnimations? = null + private var browser: BrowserConfiguration? = null + private var partial: PartialCustomTabsConfiguration? = null + + /** + * @noinspection DataFlowIssue + */ + @Suppress("UNCHECKED_CAST") + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + + colorSchemes = CustomTabsColorSchemes.Builder() + .setOptions((options[KEY_COLOR_SCHEMES] as Map?)) + .build() + urlBarHidingEnabled = options[KEY_URL_BAR_HIDING_ENABLED] as Boolean? + shareState = options[KEY_SHARE_STATE] as Int? + showTitle = options[KEY_SHOW_TITLE] as Boolean? + instantAppsEnabled = options[KEY_INSTANT_APPS_ENABLED] as Boolean? + closeButton = CustomTabsCloseButton.Builder() + .setOptions((options[KEY_CLOSE_BUTTON] as Map?)) + .build() + animations = CustomTabsAnimations.Builder() + .setOptions(options[KEY_ANIMATIONS] as Map?) + .build() + browser = BrowserConfiguration.Builder() + .setOptions(options[KEY_BROWSER] as Map?) + .build() + partial = PartialCustomTabsConfiguration.Builder() + .setOptions(options[KEY_PARTIAL] as Map?) + .build() + return this + } + + fun setColorSchemes(colorSchemes: CustomTabsColorSchemes?): Builder { + this.colorSchemes = colorSchemes + return this + } + + fun setUrlBarHidingEnabled(urlBarHidingEnabled: Boolean?): Builder { + this.urlBarHidingEnabled = urlBarHidingEnabled + return this + } + + fun setShareState(@ShareState shareState: Int?): Builder { + this.shareState = shareState + return this + } + + fun setShowTitle(showTitle: Boolean?): Builder { + this.showTitle = showTitle + return this + } + + fun setInstantAppsEnabled(instantAppsEnabled: Boolean?): Builder { + this.instantAppsEnabled = instantAppsEnabled + return this + } + + fun setCloseButton(closeButton: CustomTabsCloseButton?): Builder { + this.closeButton = closeButton + return this + } + + fun setAnimations(animations: CustomTabsAnimations?): Builder { + this.animations = animations + return this + } + + fun setBrowser(browser: BrowserConfiguration?): Builder { + this.browser = browser + return this + } + + fun setPartial(partial: PartialCustomTabsConfiguration?): Builder { + this.partial = partial + return this + } + + fun build() = CustomTabsIntentOptions( + colorSchemes, + urlBarHidingEnabled, + shareState, + showTitle, + instantAppsEnabled, + closeButton, + animations, + browser, + partial + ) + + private companion object { + private const val KEY_COLOR_SCHEMES = "colorSchemes" + private const val KEY_URL_BAR_HIDING_ENABLED = "urlBarHidingEnabled" + private const val KEY_SHARE_STATE = "shareState" + private const val KEY_SHOW_TITLE = "showTitle" + private const val KEY_INSTANT_APPS_ENABLED = "instantAppsEnabled" + private const val KEY_CLOSE_BUTTON = "closeButton" + private const val KEY_ANIMATIONS = "animations" + private const val KEY_BROWSER = "browser" + private const val KEY_PARTIAL = "partial" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.kt new file mode 100644 index 00000000..4cf01b8a --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/CustomTabsSessionOptions.kt @@ -0,0 +1,64 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import android.content.Context +import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider + +class CustomTabsSessionOptions private constructor( + private val browser: BrowserConfiguration +) { + constructor( + prefersExternalBrowser: Boolean?, + fallbackCustomTabPackages: Set? + ) : this( + BrowserConfiguration.Builder() + .setPrefersExternalBrowser(prefersExternalBrowser) + .setFallbackCustomTabs(fallbackCustomTabPackages) + .build() + ) + + val prefersDefaultBrowser: Boolean? + get() = browser.prefersDefaultBrowser + + val fallbackCustomTabPackages: Set? + get() = browser.fallbackCustomTabPackages + + fun getAdditionalCustomTabs(context: Context): CustomTabsPackageProvider { + return browser.getAdditionalCustomTabs(context) + } + + class Builder { + private var prefersExternalBrowser: Boolean? = null + private var fallbackCustomTabs: Set? = null + + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + + prefersExternalBrowser = options[KEY_PREFERS_DEFAULT_BROWSER] as Boolean? + @Suppress("UNCHECKED_CAST") + fallbackCustomTabs = (options[KEY_FALLBACK_CUSTOM_TABS] as List?)?.toSet() + return this + } + + fun setPrefersDefaultBrowser(prefersExternalBrowser: Boolean?): Builder { + this.prefersExternalBrowser = prefersExternalBrowser + return this + } + + fun setFallbackCustomTabs(fallbackCustomTabs: Set?): Builder { + this.fallbackCustomTabs = fallbackCustomTabs + return this + } + + fun build() = CustomTabsSessionOptions( + prefersExternalBrowser, + fallbackCustomTabs + ) + + private companion object { + private const val KEY_PREFERS_DEFAULT_BROWSER = "prefersDefaultBrowser" + private const val KEY_FALLBACK_CUSTOM_TABS = "fallbackCustomTabs" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.kt new file mode 100644 index 00000000..06e4f6ce --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/options/PartialCustomTabsConfiguration.kt @@ -0,0 +1,58 @@ +package com.github.droibit.flutter.plugins.customtabs.core.options + +import androidx.annotation.Dimension +import androidx.browser.customtabs.CustomTabsIntent.ActivityHeightResizeBehavior + +class PartialCustomTabsConfiguration( + val initialHeight: Double?, + @ActivityHeightResizeBehavior val activityHeightResizeBehavior: Int?, + val cornerRadius: Int? +) { + class Builder { + private var initialHeight: Double? = null + private var activityHeightResizeBehavior: Int? = null + private var cornerRadius: Int? = null + + fun setOptions(options: Map?): Builder { + if (options == null) { + return this + } + + initialHeight = options[KEY_INITIAL_HEIGHT] as Double? + activityHeightResizeBehavior = options[KEY_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR] as Int? + cornerRadius = options[KEY_CORNER_RADIUS] as Int? + return this + } + + fun setInitialHeight(initialHeight: Double?): Builder { + this.initialHeight = initialHeight + return this + } + + fun setActivityHeightResizeBehavior( + @ActivityHeightResizeBehavior activityHeightResizeBehavior: Int? + ): Builder { + this.activityHeightResizeBehavior = activityHeightResizeBehavior + return this + } + + fun setCornerRadius(@Dimension(unit = Dimension.DP) cornerRadius: Int?): Builder { + this.cornerRadius = cornerRadius + return this + } + + fun build(): PartialCustomTabsConfiguration { + return PartialCustomTabsConfiguration( + initialHeight, + activityHeightResizeBehavior, + cornerRadius + ) + } + + private companion object { + private const val KEY_INITIAL_HEIGHT = "initialHeight" + private const val KEY_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR = "activityHeightResizeBehavior" + private const val KEY_CORNER_RADIUS = "cornerRadius" + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.kt new file mode 100644 index 00000000..8c4b711b --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionController.kt @@ -0,0 +1,90 @@ +package com.github.droibit.flutter.plugins.customtabs.core.session + +import android.content.ComponentName +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsService.KEY_URL +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import com.github.droibit.flutter.plugins.customtabs.core.utils.TAG +import io.flutter.Log + +class CustomTabsSessionController( + val packageName: String +) : CustomTabsServiceConnection() { + private var context: Context? = null + + @set:VisibleForTesting + internal var session: CustomTabsSession? = null + + @get:VisibleForTesting + var isCustomTabsServiceBound: Boolean = false + private set + + fun bindCustomTabsService(context: Context): Boolean { + if (isCustomTabsServiceBound) { + Log.d(TAG, "Custom Tab($packageName) already bound.") + return true + } + return tryBindCustomTabsService(context) + } + + private fun tryBindCustomTabsService(context: Context): Boolean { + try { + val bound = CustomTabsClient.bindCustomTabsService(context, packageName, this) + Log.d(TAG, "Custom Tab($packageName) bound: $bound") + if (bound) { + this.context = context + } + isCustomTabsServiceBound = bound + } catch (e: SecurityException) { + isCustomTabsServiceBound = false + } + return isCustomTabsServiceBound + } + + fun unbindCustomTabsService() { + context?.unbindService(this) + session = null + isCustomTabsServiceBound = false + Log.d(TAG, "Custom Tab($packageName) unbound.") + } + + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + val warmedUp = client.warmup(0) + Log.d(TAG, "Custom Tab(" + name.packageName + ") warmedUp: " + warmedUp) + session = client.newSession(null) + } + + override fun onServiceDisconnected(name: ComponentName) { + session = null + isCustomTabsServiceBound = false + + Log.d(TAG, "Custom Tab($packageName) disconnected.") + } + + fun mayLaunchUrls(urls: List) { + val session = this.session + if (session == null) { + Log.w(TAG, "Custom Tab session is null. Cannot may launch URLs.") + return + } + if (urls.isEmpty()) { + Log.w(TAG, "URLs is empty. Cannot may launch URLs.") + return + } + + if (urls.size == 1) { + val succeeded = session.mayLaunchUrl(urls[0].toUri(), null, null) + Log.d(TAG, "May launch URL: $succeeded") + return + } + + val bundles = urls.map { bundleOf(KEY_URL to it.toUri()) } + val succeeded = session.mayLaunchUrl(null, null, bundles) + Log.d(TAG, "May launch URL(s): $succeeded") + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.kt new file mode 100644 index 00000000..c28590dd --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManager.kt @@ -0,0 +1,63 @@ +package com.github.droibit.flutter.plugins.customtabs.core.session + +import android.content.Context +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.browser.customtabs.CustomTabsSession +import com.droibit.android.customtabs.launcher.getCustomTabsPackage +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions +import com.github.droibit.flutter.plugins.customtabs.core.utils.TAG + +class CustomTabsSessionManager @VisibleForTesting internal constructor( + private val cachedSessions: MutableMap +) : CustomTabsSessionProvider { + constructor() : this(mutableMapOf()) + + fun createSessionOptions(options: Map?): CustomTabsSessionOptions { + return CustomTabsSessionOptions.Builder() + .setOptions(options) + .build() + } + + fun createSessionController( + context: Context, + options: CustomTabsSessionOptions + ): CustomTabsSessionController? { + val prefersDefaultBrowser = options.prefersDefaultBrowser + val customTabsPackage = getCustomTabsPackage( + context, + ignoreDefault = prefersDefaultBrowser != true, + additionalCustomTabs = options.getAdditionalCustomTabs(context) + ) ?: return null + + return cachedSessions[customTabsPackage] + ?: CustomTabsSessionController(customTabsPackage).also { + cachedSessions[customTabsPackage] = it + } + } + + fun getSessionController(packageName: String): CustomTabsSessionController? { + return cachedSessions[packageName] + } + + override fun getSession(packageName: String?): CustomTabsSession? { + return packageName?.let { cachedSessions[it]?.session } + } + + fun invalidateSession(packageName: String) { + val controller = cachedSessions[packageName] ?: return + controller.unbindCustomTabsService() + cachedSessions.remove(packageName) + } + + fun handleActivityChange(activity: Context?) { + Log.d(TAG, "handleActivityChange: $activity") + for (controller in cachedSessions.values) { + if (activity == null) { + controller.unbindCustomTabsService() + } else { + controller.bindCustomTabsService(activity) + } + } + } +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.kt new file mode 100644 index 00000000..6007763c --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionProvider.kt @@ -0,0 +1,7 @@ +package com.github.droibit.flutter.plugins.customtabs.core.session + +import androidx.browser.customtabs.CustomTabsSession + +fun interface CustomTabsSessionProvider { + fun getSession(packageName: String?): CustomTabsSession? +} diff --git a/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/utils/Utils.kt b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/utils/Utils.kt new file mode 100644 index 00000000..60bb19f2 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/main/kotlin/com/github/droibit/flutter/plugins/customtabs/core/utils/Utils.kt @@ -0,0 +1,16 @@ +package com.github.droibit.flutter.plugins.customtabs.core.utils + +import android.os.Bundle + +internal const val TAG = "CustomTabsAndroid" + +internal const val CODE_LAUNCH_ERROR: String = "LAUNCH_ERROR" +internal const val REQUEST_CODE_PARTIAL_CUSTOM_TABS = 1001 + +internal fun extractBundle(headers: Map): Bundle { + return Bundle(headers.size).apply { + for ((key, value) in headers) { + putString(key, value) + } + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.java deleted file mode 100644 index 2159ac4b..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.java +++ /dev/null @@ -1,358 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.net.Uri; - -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.core.util.Pair; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.github.droibit.flutter.plugins.customtabs.Messages.FlutterError; -import com.github.droibit.flutter.plugins.customtabs.core.CustomTabsIntentFactory; -import com.github.droibit.flutter.plugins.customtabs.core.ExternalBrowserLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.NativeAppLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.PartialCustomTabsLauncher; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionController; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionManager; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.Config; - -import java.util.Collections; -import java.util.Map; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class CustomTabsLauncherTest { - @Rule - public MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Mock - private CustomTabsIntentFactory customTabsIntentFactory; - - @Mock - private NativeAppLauncher nativeAppLauncher; - - @Mock - private ExternalBrowserLauncher externalBrowserLauncher; - - @Mock - private PartialCustomTabsLauncher partialCustomTabsLauncher; - - @Mock - private CustomTabsSessionManager customTabsSessionManager; - - @InjectMocks - private CustomTabsLauncher launcher; - - @Test - public void launch_withoutActivity_throwsException() { - launcher.setActivity(null); - - try { - launcher.launch("https://example.com", false, null); - fail("error"); - } catch (Exception e) { - assertThat(e).isInstanceOf(FlutterError.class); - - final FlutterError actualError = ((FlutterError) e); - assertThat(actualError.code).isEqualTo(CustomTabsLauncher.CODE_LAUNCH_ERROR); - } - - verify(nativeAppLauncher, never()).launch(any(), any()); - } - - @Test - public void launch_withNativeApp() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - when(nativeAppLauncher.launch(any(), any())).thenReturn(true); - - try { - final String expUrl = "https://example.com"; - launcher.launch(expUrl, true, null); - - verify(nativeAppLauncher).launch(same(activity), eq(Uri.parse(expUrl))); - } catch (Exception e) { - fail(e.getMessage()); - } - - verify(externalBrowserLauncher, never()).launch(any(), any(), any()); - verify(customTabsIntentFactory, never()).createIntent(any(), any(), any()); - } - - @Test - public void launch_withExternalBrowser() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(null); - when(externalBrowserLauncher.launch(any(), any(), any())).thenReturn(true); - - try { - final Uri expUrl = Uri.parse("https://example.com"); - launcher.launch(expUrl.toString(), false, null); - - verify(externalBrowserLauncher).launch(any(), eq(expUrl), isNull()); - verify(customTabsIntentFactory, never()).createIntent(any(), any(), any()); - } catch (Exception e) { - fail(e.getMessage()); - } - } - - @Test - public void launch_withExternalBrowser_throwsException() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(null); - - final ActivityNotFoundException anf = mock(ActivityNotFoundException.class); - doThrow(anf).when(externalBrowserLauncher).launch(any(), any(), any()); - - final Uri uri = Uri.parse("https://example.com"); - try { - launcher.launch(uri.toString(), false, null); - fail("error"); - } catch (Exception e) { - assertThat(e).isInstanceOf(FlutterError.class); - - final FlutterError actualError = ((FlutterError) e); - assertThat(actualError.code).isEqualTo(CustomTabsLauncher.CODE_LAUNCH_ERROR); - } - - verify(externalBrowserLauncher).launch(any(), eq(uri), isNull()); - verify(customTabsIntentFactory, never()).createIntent(any(), any(), any()); - } - - @Test - public void launch_withPartialCustomTabs() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsIntentOptions intentOptions = mock(CustomTabsIntentOptions.class); - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(intentOptions); - when(externalBrowserLauncher.launch(any(), any(), any())).thenReturn(false); - - final CustomTabsIntent customTabsIntent = spy( - new CustomTabsIntent.Builder() - .setInitialActivityHeightPx(100) - .build() - ); - when(customTabsIntentFactory.createIntent(any(), any(), any())).thenReturn(customTabsIntent); - when(partialCustomTabsLauncher.launch(any(), any(), any())).thenReturn(true); - - try { - final String expUrl = "https://example.com"; - final Map options = Collections.emptyMap(); - launcher.launch(expUrl, false, options); - - verify(customTabsIntentFactory).createIntent(any(), same(intentOptions), any()); - verify(partialCustomTabsLauncher).launch(any(), any(), same(customTabsIntent)); - verify(customTabsIntent, never()).launchUrl(any(), any()); - } catch (Exception e) { - fail(e.getMessage()); - } - } - - @Test - public void launch_withPartialCustomTabs_throwsException() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsIntentOptions intentOptions = mock(CustomTabsIntentOptions.class); - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(intentOptions); - when(externalBrowserLauncher.launch(any(), any(), any())).thenReturn(false); - - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .setInitialActivityHeightPx(100) - .build(); - when(customTabsIntentFactory.createIntent(any(), any(), any())).thenReturn(customTabsIntent); - - final ActivityNotFoundException anf = mock(ActivityNotFoundException.class); - doThrow(anf).when(partialCustomTabsLauncher).launch(any(), any(), any()); - - try { - final String expUrl = "https://example.com"; - final Map options = Collections.emptyMap(); - launcher.launch(expUrl, false, options); - fail("error"); - } catch (Exception e) { - assertThat(e).isInstanceOf(FlutterError.class); - - final FlutterError actualError = ((FlutterError) e); - assertThat(actualError.code).isEqualTo(CustomTabsLauncher.CODE_LAUNCH_ERROR); - } - - verify(customTabsIntentFactory).createIntent(any(), same(intentOptions), any()); - verify(partialCustomTabsLauncher).launch(any(), any(), same(customTabsIntent)); - } - - @Test - public void launch_withCustomTabs() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsIntentOptions intentOptions = mock(CustomTabsIntentOptions.class); - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(intentOptions); - when(externalBrowserLauncher.launch(any(), any(), any())).thenReturn(false); - - final CustomTabsIntent customTabsIntent = spy(new CustomTabsIntent.Builder().build()); - when(customTabsIntentFactory.createIntent(any(), any(), any())).thenReturn(customTabsIntent); - when(partialCustomTabsLauncher.launch(any(), any(), any())).thenReturn(false); - - try { - final String expUrl = "https://example.com"; - final Map options = Collections.emptyMap(); - launcher.launch(expUrl, false, options); - - final ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(Uri.class); - verify(customTabsIntent).launchUrl(any(), urlCaptor.capture()); - - final Uri actualUrl = urlCaptor.getValue(); - assertThat(actualUrl).isEqualTo(Uri.parse(expUrl)); - - verify(customTabsIntentFactory).createIntent(any(), same(intentOptions), any()); - } catch (Exception e) { - fail(e.getMessage()); - } - } - - @Test - public void launch_withCustomTabs_throwsException() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsIntentOptions intentOptions = mock(CustomTabsIntentOptions.class); - when(customTabsIntentFactory.createIntentOptions(any())).thenReturn(intentOptions); - when(externalBrowserLauncher.launch(any(), any(), any())).thenReturn(false); - - final CustomTabsIntent customTabsIntent = spy(new CustomTabsIntent.Builder().build()); - when(customTabsIntentFactory.createIntent(any(), any(), any())).thenReturn(customTabsIntent); - - final ActivityNotFoundException anf = mock(ActivityNotFoundException.class); - doThrow(anf).when(customTabsIntent).launchUrl(any(), any()); - - when(partialCustomTabsLauncher.launch(any(), any(), any())).thenReturn(false); - - try { - final Map options = Collections.emptyMap(); - launcher.launch("https://example.com", false, options); - fail("error"); - } catch (Exception e) { - assertThat(e).isInstanceOf(FlutterError.class); - - final FlutterError actualError = ((FlutterError) e); - assertThat(actualError.code).isEqualTo(CustomTabsLauncher.CODE_LAUNCH_ERROR); - } - - verify(customTabsIntentFactory).createIntent(any(), same(intentOptions), any()); - } - - @Test - public void warmup_withSessionOptions_returnsPackageName() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsSessionOptions sessionOptions = mock(CustomTabsSessionOptions.class); - when(customTabsSessionManager.createSessionOptions(any())).thenReturn(sessionOptions); - - final String expectedPackageName = "com.example.browser"; - final CustomTabsSessionController controller = mock(CustomTabsSessionController.class); - when(controller.getPackageName()).thenReturn(expectedPackageName); - when(controller.bindCustomTabsService(any())).thenReturn(true); - - when(customTabsSessionManager.createSessionController(any(), any())) - .thenReturn(controller); - - final Map options = Collections.emptyMap(); - final String actualPackageName = launcher.warmup(options); - assertThat(actualPackageName).isEqualTo(expectedPackageName); - - verify(customTabsSessionManager).createSessionOptions(same(options)); - verify(controller).bindCustomTabsService(any()); - } - - @Test - public void warmup_withSessionOptions_returnsNull() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsSessionOptions sessionOptions = mock(CustomTabsSessionOptions.class); - when(customTabsSessionManager.createSessionOptions(any())).thenReturn(sessionOptions); - - when(customTabsSessionManager.createSessionController(any(), any())) - .thenReturn(null); - - final Map options = Collections.emptyMap(); - final String actualPackageName = launcher.warmup(options); - assertThat(actualPackageName).isNull(); - - verify(customTabsSessionManager).createSessionOptions(same(options)); - } - - @Test - public void warmup_withSessionOptions_bindCustomTabsServiceReturnsFalse() { - final Activity activity = mock(Activity.class); - launcher.setActivity(activity); - - final CustomTabsSessionOptions sessionOptions = mock(CustomTabsSessionOptions.class); - when(customTabsSessionManager.createSessionOptions(any())).thenReturn(sessionOptions); - - final CustomTabsSessionController controller = mock(CustomTabsSessionController.class); - when(controller.bindCustomTabsService(any())).thenReturn(false); - - when(customTabsSessionManager.createSessionController(any(), any())) - .thenReturn(controller); - - final Map options = Collections.emptyMap(); - final String actualPackageName = launcher.warmup(options); - assertThat(actualPackageName).isNull(); - - verify(customTabsSessionManager).createSessionOptions(same(options)); - verify(controller).bindCustomTabsService(any()); - } - - @Test - public void warmup_withoutActivity_returnsNull() { - launcher.setActivity(null); - - final String actualPackageName = launcher.warmup(Collections.emptyMap()); - assertThat(actualPackageName).isNull(); - - verify(customTabsSessionManager, never()).createSessionOptions(any()); - verify(customTabsSessionManager, never()).createSessionController(any(), any()); - } - - @Test - public void invalidate_withValidPackageName_invokesInvalidateSession() throws Exception { - final String packageName = "com.example.browser"; - launcher.invalidate(packageName); - - verify(customTabsSessionManager).invalidateSession(eq(packageName)); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.java deleted file mode 100644 index 75196a13..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.java +++ /dev/null @@ -1,539 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; -import static androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_HEIGHT_FIXED; -import static androidx.browser.customtabs.CustomTabsIntent.CLOSE_BUTTON_POSITION_DEFAULT; -import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK; -import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT; -import static androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_CLOSE_BUTTON_ICON; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_CLOSE_BUTTON_POSITION; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_INSTANT_APPS; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_URLBAR_HIDING; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_SHARE_STATE; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE; -import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP; -import static androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF; -import static androidx.browser.customtabs.CustomTabsIntent.SHOW_PAGE_TITLE; -import static androidx.test.ext.truth.content.IntentSubject.assertThat; -import static androidx.test.ext.truth.os.BundleSubject.assertThat; -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.setChromeCustomTabsPackage; -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.setCustomTabsPackage; -import static com.github.droibit.flutter.plugins.customtabs.core.ResourceFactory.INVALID_RESOURCE_ID; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyDouble; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNotNull; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -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 static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singleton; -import static java.util.Collections.singletonMap; - -import android.content.Context; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.os.Bundle; -import android.provider.Browser; - -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.ext.truth.os.BundleSubject; - -import com.droibit.android.customtabs.launcher.CustomTabsIntentHelper; -import com.droibit.android.customtabs.launcher.NonChromeCustomTabs; -import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsAnimations; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsCloseButton; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsColorSchemes; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; -import com.github.droibit.flutter.plugins.customtabs.core.options.PartialCustomTabsConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionProvider; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.Config; - -import java.util.AbstractMap.SimpleEntry; -import java.util.List; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class CustomTabsIntentFactoryTest { - @Rule - public MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Mock - private ResourceFactory resources; - - @Mock - private CustomTabsSessionProvider sessionProvider; - - @Mock - private Context context; - - @InjectMocks - private CustomTabsIntentFactory factory; - - @Test - public void createIntent_completeOptions() { - final CustomTabsColorSchemes expColorSchemes = mock(CustomTabsColorSchemes.class); - final boolean expUrlBarHidingEnabled = true; - final int expShareState = SHARE_STATE_OFF; - final boolean expShowTitle = true; - final boolean expInstantAppsEnabled = false; - final CustomTabsCloseButton expCloseButton = mock(CustomTabsCloseButton.class); - final CustomTabsAnimations expAnimations = mock(CustomTabsAnimations.class); - final PartialCustomTabsConfiguration expPartial = mock(PartialCustomTabsConfiguration.class); - final BrowserConfiguration expBrowser = mock(BrowserConfiguration.class); - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .setColorSchemes(expColorSchemes) - .setCloseButton(expCloseButton) - .setUrlBarHidingEnabled(expUrlBarHidingEnabled) - .setShareState(expShareState) - .setShowTitle(expShowTitle) - .setInstantAppsEnabled(expInstantAppsEnabled) - .setAnimations(expAnimations) - .setPartial(expPartial) - .setBrowser(expBrowser) - .build(); - - final CustomTabsIntentFactory intentFactory = spy(this.factory); - doNothing().when(intentFactory).applyColorSchemes(any(), any()); - doNothing().when(intentFactory).applyCloseButton(any(), any(), any()); - doNothing().when(intentFactory).applyAnimations(any(), any(), any()); - doNothing().when(intentFactory).applyPartialCustomTabsConfiguration(any(), any(), any()); - doNothing().when(intentFactory).applyBrowserConfiguration(any(), any(), any()); - - final CustomTabsIntent customTabsIntent = intentFactory - .createIntent(context, options, sessionProvider); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - - final ArgumentCaptor colorSchemesCaptor = ArgumentCaptor.captor(); - verify(intentFactory).applyColorSchemes(any(), colorSchemesCaptor.capture()); - assertThat(colorSchemesCaptor.getValue()).isSameInstanceAs(expColorSchemes); - - final ArgumentCaptor closeButtonCaptor = ArgumentCaptor.forClass(CustomTabsCloseButton.class); - verify(intentFactory).applyCloseButton(any(), any(), closeButtonCaptor.capture()); - assertThat(closeButtonCaptor.getValue()).isSameInstanceAs(expCloseButton); - - extras.bool(EXTRA_ENABLE_URLBAR_HIDING).isEqualTo(expUrlBarHidingEnabled); - extras.integer(EXTRA_SHARE_STATE).isEqualTo(expShareState); - extras.integer(EXTRA_TITLE_VISIBILITY_STATE).isEqualTo(SHOW_PAGE_TITLE); - extras.bool(EXTRA_ENABLE_INSTANT_APPS).isEqualTo(expInstantAppsEnabled); - - final ArgumentCaptor animationsCaptor = ArgumentCaptor.forClass(CustomTabsAnimations.class); - verify(intentFactory).applyAnimations(any(), any(), animationsCaptor.capture()); - assertThat(animationsCaptor.getValue()).isSameInstanceAs(expAnimations); - - final ArgumentCaptor partialCaptor = ArgumentCaptor.forClass(PartialCustomTabsConfiguration.class); - verify(intentFactory).applyPartialCustomTabsConfiguration(any(), any(), partialCaptor.capture()); - assertThat(partialCaptor.getValue()).isSameInstanceAs(expPartial); - - final ArgumentCaptor browserCaptor = ArgumentCaptor.forClass(BrowserConfiguration.class); - verify(intentFactory).applyBrowserConfiguration(any(), any(), browserCaptor.capture()); - assertThat(browserCaptor.getValue()).isSameInstanceAs(expBrowser); - } - - @Test - public void createIntent_minimumOptions() { - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .build(); - final CustomTabsIntentFactory intentFactory = spy(this.factory); - doNothing().when(intentFactory).applyColorSchemes(any(), any()); - doNothing().when(intentFactory).applyCloseButton(any(), any(), any()); - doNothing().when(intentFactory).applyAnimations(any(), any(), any()); - doNothing().when(intentFactory).applyPartialCustomTabsConfiguration(any(), any(), any()); - doNothing().when(intentFactory).applyBrowserConfiguration(any(), any(), any()); - - final CustomTabsIntent customTabsIntent = intentFactory - .createIntent(context, options, sessionProvider); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.doesNotContainKey(EXTRA_ENABLE_URLBAR_HIDING); - extras.doesNotContainKey(EXTRA_TITLE_VISIBILITY_STATE); - // It seems that CustomTabsIntent includes these extras by default. - extras.containsKey(EXTRA_SHARE_STATE); - extras.containsKey(EXTRA_ENABLE_INSTANT_APPS); - - verify(intentFactory, never()).applyColorSchemes(any(), any()); - verify(intentFactory, never()).applyCloseButton(any(), any(), any()); - verify(intentFactory, never()).applyAnimations(any(), any(), any()); - verify(intentFactory, never()).applyPartialCustomTabsConfiguration(any(), any(), any()); - - final ArgumentCaptor browserConfigCaptor = - ArgumentCaptor.forClass(BrowserConfiguration.class); - verify(intentFactory).applyBrowserConfiguration(any(), any(), browserConfigCaptor.capture()); - - final BrowserConfiguration actualBrowserConfig = browserConfigCaptor.getValue(); - assertThat(actualBrowserConfig.getPrefersExternalBrowser()).isFalse(); - assertThat(actualBrowserConfig.getPrefersDefaultBrowser()).isNull(); - assertThat(actualBrowserConfig.getFallbackCustomTabPackages()).isNull(); - assertThat(actualBrowserConfig.getHeaders()).isNull(); - } - - /** - * @noinspection DataFlowIssue - */ - @Test - public void applyColorSchemes_completeOptions() { - final CustomTabColorSchemeParams expLightParams = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(0xFFFFDBA0) - .setNavigationBarDividerColor(0xFFFFDBA1) - .setNavigationBarColor(0xFFFFDBA2) - .build(); - final CustomTabColorSchemeParams expDarkParams = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(0xFFFFDBA3) - .setNavigationBarDividerColor(0xFFFFDBA4) - .setNavigationBarColor(0xFFFFDBA5) - .build(); - final CustomTabColorSchemeParams expDefaultParams = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(0xFFFFDBA5) - .setNavigationBarDividerColor(0xFFFFDBA6) - .setNavigationBarColor(0xFFFFDBA7) - .build(); - final int expColorScheme = COLOR_SCHEME_SYSTEM; - final CustomTabsColorSchemes options = new CustomTabsColorSchemes.Builder() - .setColorScheme(expColorScheme) - .setLightParams( - new CustomTabColorSchemeParams.Builder() - .setToolbarColor(expLightParams.toolbarColor) - .setNavigationBarColor(expLightParams.navigationBarColor) - .setNavigationBarDividerColor(expLightParams.navigationBarDividerColor) - .build() - ) - .setDarkParams( - new CustomTabColorSchemeParams.Builder() - .setToolbarColor(expDarkParams.toolbarColor) - .setNavigationBarColor(expDarkParams.navigationBarColor) - .setNavigationBarDividerColor(expDarkParams.navigationBarDividerColor) - .build() - ) - .setDefaultParams( - new CustomTabColorSchemeParams.Builder() - .setToolbarColor(expDefaultParams.toolbarColor) - .setNavigationBarColor(expDefaultParams.navigationBarColor) - .setNavigationBarDividerColor(expDefaultParams.navigationBarDividerColor) - .build() - ) - .build(); - final CustomTabsIntent.Builder builder = mock(CustomTabsIntent.Builder.class); - factory.applyColorSchemes(builder, options); - - final ArgumentCaptor schemeCaptor = ArgumentCaptor.forClass(Integer.class); - final ArgumentCaptor paramsCaptor - = ArgumentCaptor.forClass(CustomTabColorSchemeParams.class); - verify(builder).setColorScheme(schemeCaptor.capture()); - verify(builder, times(2)).setColorSchemeParams( - schemeCaptor.capture(), - paramsCaptor.capture() - ); - verify(builder).setDefaultColorSchemeParams(paramsCaptor.capture()); - - final List actualColorSchemes = schemeCaptor.getAllValues(); - assertThat(actualColorSchemes).containsExactly( - expColorScheme, - COLOR_SCHEME_LIGHT, - COLOR_SCHEME_DARK - ); - - final List actualParams = paramsCaptor.getAllValues(); - assertThat(actualParams).hasSize(3); - - final CustomTabColorSchemeParams actualLightParams = actualParams.get(0); - assertThat(actualLightParams.toolbarColor).isEqualTo(expLightParams.toolbarColor); - assertThat(actualLightParams.navigationBarColor) - .isEqualTo(expLightParams.navigationBarColor); - assertThat(actualLightParams.navigationBarDividerColor) - .isEqualTo(expLightParams.navigationBarDividerColor); - - final CustomTabColorSchemeParams actualDarkParams = actualParams.get(1); - assertThat(actualDarkParams.toolbarColor).isEqualTo(expDarkParams.toolbarColor); - assertThat(actualDarkParams.navigationBarColor) - .isEqualTo(expDarkParams.navigationBarColor); - assertThat(actualDarkParams.navigationBarDividerColor) - .isEqualTo(expDarkParams.navigationBarDividerColor); - - final CustomTabColorSchemeParams actualDefaultParams = actualParams.get(2); - assertThat(actualDefaultParams.toolbarColor).isEqualTo(expDefaultParams.toolbarColor); - assertThat(actualDefaultParams.navigationBarColor) - .isEqualTo(expDefaultParams.navigationBarColor); - assertThat(actualDefaultParams.navigationBarDividerColor) - .isEqualTo(expDefaultParams.navigationBarDividerColor); - } - - @Test - public void applyColorSchemes_minimumOptions() { - final CustomTabsColorSchemes options = new CustomTabsColorSchemes.Builder() - .build(); - final CustomTabsIntent.Builder builder = mock(CustomTabsIntent.Builder.class); - factory.applyColorSchemes(builder, options); - - verify(builder, never()).setColorScheme(anyInt()); - verify(builder, never()).setColorSchemeParams(anyInt(), any()); - verify(builder, never()).setDefaultColorSchemeParams(any()); - } - - @Test - public void applyCloseButton_completeOptions() { - final Bitmap expIcon = mock(Bitmap.class); - when(resources.getBitmap(any(), anyString())).thenReturn(expIcon); - - final int expPosition = CLOSE_BUTTON_POSITION_DEFAULT; - final CustomTabsCloseButton options = new CustomTabsCloseButton.Builder() - .setIcon("icon") - .setPosition(expPosition) - .build(); - - final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); - factory.applyCloseButton(context, builder, options); - - final CustomTabsIntent customTabsIntent = builder.build(); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.isNotNull(); - extras.parcelable(EXTRA_CLOSE_BUTTON_ICON).isSameInstanceAs(expIcon); - extras.integer(EXTRA_CLOSE_BUTTON_POSITION).isEqualTo(expPosition); - } - - @Test - public void applyCloseButton_minimumOptions() { - final CustomTabsCloseButton options = new CustomTabsCloseButton.Builder() - .build(); - final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); - factory.applyCloseButton(context, builder, options); - - final CustomTabsIntent customTabsIntent = builder.build(); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_ICON); - extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_POSITION); - } - - @Test - public void applyCloseButton_invalidIcon() { - when(resources.getBitmap(any(), anyString())).thenReturn(null); - - final CustomTabsCloseButton options = new CustomTabsCloseButton.Builder() - .setIcon("icon") - .build(); - final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); - factory.applyCloseButton(context, builder, options); - - final CustomTabsIntent customTabsIntent = builder.build(); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_ICON); - extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_POSITION); - } - - @Test - public void applyAnimations_completeOptions() { - final int expStartEnter = 1; - final int expStartExit = 2; - final int expEndEnter = 3; - final int expEndExit = 4; - when(resources.getAnimationIdentifier(any(), eq("start_enter"))) - .thenReturn(expStartEnter); - when(resources.getAnimationIdentifier(any(), eq("start_exit"))) - .thenReturn(expStartExit); - when(resources.getAnimationIdentifier(any(), eq("end_enter"))) - .thenReturn(expEndEnter); - when(resources.getAnimationIdentifier(any(), eq("end_exit"))) - .thenReturn(expEndExit); - - final CustomTabsAnimations options = new CustomTabsAnimations.Builder() - .setStartEnter("start_enter") - .setStartExit("start_exit") - .setEndEnter("end_enter") - .setEndExit("end_exit") - .build(); - final CustomTabsIntent.Builder builder = mock(CustomTabsIntent.Builder.class); - factory.applyAnimations(context, builder, options); - - final ArgumentCaptor startEnterCaptor = ArgumentCaptor.forClass(Integer.class); - final ArgumentCaptor startExitCaptor = ArgumentCaptor.forClass(Integer.class); - verify(builder).setStartAnimations( - any(), - startEnterCaptor.capture(), - startExitCaptor.capture() - ); - assertThat(startEnterCaptor.getValue()).isEqualTo(expStartEnter); - assertThat(startExitCaptor.getValue()).isEqualTo(expStartExit); - - final ArgumentCaptor endEnterCaptor = ArgumentCaptor.forClass(Integer.class); - final ArgumentCaptor endExitCaptor = ArgumentCaptor.forClass(Integer.class); - verify(builder).setExitAnimations( - any(), - endEnterCaptor.capture(), - endExitCaptor.capture() - ); - assertThat(endEnterCaptor.getValue()).isEqualTo(expEndEnter); - assertThat(endExitCaptor.getValue()).isEqualTo(expEndExit); - } - - @Test - public void applyAnimations_emptyOptions() { - when(resources.getAnimationIdentifier(any(), anyString())) - .thenReturn(INVALID_RESOURCE_ID); - - final CustomTabsAnimations options = new CustomTabsAnimations.Builder() - .build(); - final CustomTabsIntent.Builder builder = mock(CustomTabsIntent.Builder.class); - factory.applyAnimations(context, builder, options); - - verify(builder, never()).setStartAnimations(any(), anyInt(), anyInt()); - verify(builder, never()).setExitAnimations(any(), anyInt(), anyInt()); - } - - @Test - public void applyPartialCustomTabsConfiguration_completeOptions() { - final int expCornerRadius = 8; - final PartialCustomTabsConfiguration options = new PartialCustomTabsConfiguration.Builder() - .setActivityHeightResizeBehavior(ACTIVITY_HEIGHT_FIXED) - .setInitialHeight(100.0) - .setCornerRadius(expCornerRadius) - .build(); - - final int expInitialActivityHeight = 100; - when(resources.convertToPx(any(), anyDouble())).thenReturn(expInitialActivityHeight); - - final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); - factory.applyPartialCustomTabsConfiguration(context, builder, options); - - final CustomTabsIntent customTabsIntent = builder.build(); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.integer(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR).isEqualTo(ACTIVITY_HEIGHT_FIXED); - extras.integer(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX).isEqualTo(expInitialActivityHeight); - extras.integer(EXTRA_TOOLBAR_CORNER_RADIUS_DP).isEqualTo(expCornerRadius); - } - - @Test - public void applyPartialCustomTabsConfiguration_minimumOptions() { - final PartialCustomTabsConfiguration options = new PartialCustomTabsConfiguration.Builder() - .setInitialHeight(200.0) - .build(); - - final int expInitialActivityHeight = 200; - when(resources.convertToPx(any(), anyDouble())).thenReturn(expInitialActivityHeight); - - final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); - factory.applyPartialCustomTabsConfiguration(context, builder, options); - - final CustomTabsIntent customTabsIntent = builder.build(); - final BundleSubject extras = assertThat(customTabsIntent.intent).extras(); - extras.integer(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR).isEqualTo(ACTIVITY_HEIGHT_DEFAULT); - extras.integer(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX).isEqualTo(expInitialActivityHeight); - extras.doesNotContainKey(EXTRA_TOOLBAR_CORNER_RADIUS_DP); - } - - @Test - public void applyBrowserConfiguration_completeOptionsWithPrefersChrome() { - try (MockedStatic mocked = mockStatic(CustomTabsIntentHelper.class)) { - mocked.when(() -> setChromeCustomTabsPackage(any(), any(), any())) - .thenReturn(null); - - final SimpleEntry expHeader = new SimpleEntry<>("key", "value"); - final BrowserConfiguration options = new BrowserConfiguration.Builder() - .setHeaders(singletonMap(expHeader.getKey(), expHeader.getValue())) - .setFallbackCustomTabs(singleton("com.example.customtabs")) - .setPrefersExternalBrowser(false) - .setPrefersDefaultBrowser(false) - .build(); - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .build(); - factory.applyBrowserConfiguration(context, customTabsIntent, options); - - assertThat(customTabsIntent.intent).extras().containsKey(Browser.EXTRA_HEADERS); - final Bundle actualHeaders = customTabsIntent.intent.getBundleExtra(Browser.EXTRA_HEADERS); - assertThat(actualHeaders).isNotNull(); - assertThat(actualHeaders).hasSize(1); - assertThat(actualHeaders).string(expHeader.getKey()).isEqualTo(expHeader.getValue()); - - mocked.verify(() -> - setChromeCustomTabsPackage(any(), any(), isNotNull(NonChromeCustomTabs.class)) - ); - } - } - - @SuppressWarnings("deprecation") - @Test - public void applyBrowserConfiguration_minimumOptionsWithPrefersChrome() { - try (MockedStatic mockedHelper = mockStatic(CustomTabsIntentHelper.class)) { - mockedHelper.when(() -> setChromeCustomTabsPackage(any(), any(), any())) - .thenReturn(null); - - final PackageManager pm = mock(PackageManager.class); - when(pm.queryIntentActivities(any(), anyInt())).thenReturn(emptyList()); - when(context.getPackageManager()).thenReturn(pm); - - final BrowserConfiguration options = new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(false) - .build(); - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .build(); - factory.applyBrowserConfiguration(context, customTabsIntent, options); - - assertThat(customTabsIntent.intent).extras().doesNotContainKey(Browser.EXTRA_HEADERS); - - mockedHelper.verify(() -> - setChromeCustomTabsPackage(any(), any(), isNotNull(NonChromeCustomTabs.class)) - ); - } - } - - @SuppressWarnings("deprecation") - @Test - public void applyBrowserConfiguration_prefersDefaultBrowser() { - try (MockedStatic mocked = mockStatic(CustomTabsIntentHelper.class)) { - mocked.when(() -> setCustomTabsPackage(any(), any(), any())) - .thenReturn(null); - - final PackageManager pm = mock(PackageManager.class); - when(pm.queryIntentActivities(any(), anyInt())).thenReturn(emptyList()); - when(context.getPackageManager()).thenReturn(pm); - - final BrowserConfiguration options = new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(false) - .setPrefersDefaultBrowser(true) - .build(); - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .build(); - factory.applyBrowserConfiguration(context, customTabsIntent, options); - - assertThat(customTabsIntent.intent).extras().doesNotContainKey(Browser.EXTRA_HEADERS); - - mocked.verify(() -> - setCustomTabsPackage(any(), any(), isNotNull(NonChromeCustomTabs.class)) - ); - } - } - - @Test - public void createIntentOptions_nullOptions() { - final CustomTabsIntentOptions options = factory.createIntentOptions(null); - assertThat(options).isNull(); - } - - @Test - public void createIntentOptions_notNullOptions() { - final CustomTabsIntentOptions options = factory.createIntentOptions(emptyMap()); - assertThat(options).isNotNull(); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.java deleted file mode 100644 index 6b194a81..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import static androidx.test.ext.truth.content.IntentSubject.assertThat; -import static androidx.test.ext.truth.os.BundleSubject.assertThat; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static java.util.Collections.singletonMap; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.provider.Browser; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.Config; - -import java.util.AbstractMap; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class ExternalBrowserLauncherTest { - @Rule - public MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Mock - private Context context; - - @InjectMocks - private ExternalBrowserLauncher launcher; - - @Test - public void launch_withNullIntent_returnsFalse() { - final ExternalBrowserLauncher launcher = spy(this.launcher); - doReturn(null).when(launcher).createIntent(any()); - - final Uri uri = Uri.parse("https://example.com"); - final boolean result = launcher.launch(context, uri, null); - assertThat(result).isFalse(); - - verify(context, never()).startActivity(any()); - } - - @Test - public void launch_withValidIntent_returnsTrue() { - final ExternalBrowserLauncher launcher = spy(this.launcher); - final Intent intent = new Intent(); - doReturn(intent).when(launcher).createIntent(any()); - - final Uri uri = Uri.parse("https://example.com"); - final boolean result = launcher.launch(context, uri, null); - assertThat(result).isTrue(); - - assertThat(intent).hasData(uri); - verify(context).startActivity(same(intent)); - } - - @Test - public void createIntent_nullOptions() { - final Intent result = launcher.createIntent(null); - assertThat(result).isNotNull(); - assertThat(result).hasAction(Intent.ACTION_VIEW); - assertThat(result).extras().isNull(); - } - - @Test - public void createIntent_emptyBrowserConfiguration() { - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .setBrowser(null) - .build(); - final Intent result = launcher.createIntent(options); - assertThat(result).isNull(); - } - - @Test - public void createIntent_prefersCustomTabs() { - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .setBrowser( - new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(false) - .build() - ) - .build(); - final Intent result = launcher.createIntent(options); - assertThat(result).isNull(); - } - - @Test - public void createIntent_noHeaders() { - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .setBrowser( - new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(true) - .build() - ) - .build(); - final Intent result = launcher.createIntent(options); - assertThat(result).isNotNull(); - assertThat(result).hasAction(Intent.ACTION_VIEW); - assertThat(result).extras().isNull(); - } - - @Test - public void createIntent_addedHeaders() { - final AbstractMap.SimpleEntry expHeader = new AbstractMap.SimpleEntry<>("key", "value"); - final CustomTabsIntentOptions options = new CustomTabsIntentOptions.Builder() - .setBrowser( - new BrowserConfiguration.Builder() - .setPrefersExternalBrowser(true) - .setHeaders(singletonMap(expHeader.getKey(), expHeader.getValue())) - .build() - ) - .build(); - final Intent result = launcher.createIntent(options); - assertThat(result).isNotNull(); - assertThat(result).hasAction(Intent.ACTION_VIEW); - assertThat(result).extras().hasSize(1); - - //noinspection DataFlowIssue - final Bundle actualHeaders = result.getBundleExtra(Browser.EXTRA_HEADERS); - assertThat(actualHeaders).isNotNull(); - assertThat(actualHeaders).hasSize(1); - assertThat(actualHeaders).string(expHeader.getKey()).isEqualTo(expHeader.getValue()); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.java deleted file mode 100644 index b9e2224c..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core; - -import static androidx.test.ext.truth.content.IntentSubject.assertThat; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import android.app.Activity; -import android.net.Uri; - -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; -import org.robolectric.annotation.Config; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class PartialCustomTabsLauncherTest { - @Rule - public MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Mock - private Activity activity; - - @InjectMocks - private PartialCustomTabsLauncher launcher; - - @Test - public void launch_withValidCustomTabsIntent_returnsTrue() { - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .setInitialActivityHeightPx(100) - .build(); - - final Uri uri = Uri.parse("https://example.com"); - final boolean result = launcher.launch(activity, uri, customTabsIntent); - assertThat(result).isTrue(); - assertThat(customTabsIntent.intent).hasData(uri); - verify(activity).startActivityForResult(same(customTabsIntent.intent), eq(1001)); - } - - @Test - public void launch_withoutRequiredExtras_returnsFalse() { - final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .build(); - - final Uri uri = Uri.parse("https://example.com"); - final boolean result = launcher.launch(activity, uri, customTabsIntent); - assertThat(result).isFalse(); - - verify(activity, never()).startActivityForResult(any(), anyInt()); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.java deleted file mode 100644 index 7b3e9052..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.session; - -import static androidx.browser.customtabs.CustomTabsService.KEY_URL; -import static androidx.test.ext.truth.os.BundleSubject.assertThat; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.content.ComponentName; -import android.content.Context; -import android.net.Uri; -import android.os.Bundle; - -import androidx.browser.customtabs.CustomTabsClient; -import androidx.browser.customtabs.CustomTabsSession; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.MockedStatic; -import org.robolectric.annotation.Config; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class CustomTabsSessionControllerTest { - private final String packageName = "com.example.browser"; - - private CustomTabsSessionController controller; - - @Before - public void setUp() throws Exception { - controller = new CustomTabsSessionController(packageName); - } - - @Test - public void bindCustomTabsService_withValidPackageName_returnsTrue() { - try (MockedStatic mocked = mockStatic(CustomTabsClient.class)) { - mocked.when(() -> CustomTabsClient.bindCustomTabsService(any(), anyString(), any())) - .thenReturn(true); - - final Context context = mock(Context.class); - final boolean result = controller.bindCustomTabsService(context); - assertThat(result).isTrue(); - assertThat(controller.isCustomTabsServiceBound()).isTrue(); - - mocked.verify( - () -> CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) - ); - } - } - - @Test - public void bindCustomTabsService_withInvalidPackageName_returnsFalse() { - try (MockedStatic mocked = mockStatic(CustomTabsClient.class)) { - mocked.when(() -> CustomTabsClient.bindCustomTabsService(any(), anyString(), any())) - .thenReturn(false); - - final Context context = mock(Context.class); - final boolean result = controller.bindCustomTabsService(context); - assertThat(result).isFalse(); - assertThat(controller.isCustomTabsServiceBound()).isFalse(); - - mocked.verify( - () -> CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) - ); - } - } - - @Test - public void bindCustomTabsService_withSecurityException_returnsFalse() { - try (MockedStatic mocked = mockStatic(CustomTabsClient.class)) { - mocked.when(() -> CustomTabsClient.bindCustomTabsService(any(), anyString(), any())) - .thenThrow(new SecurityException()); - - final Context context = mock(Context.class); - final boolean result = controller.bindCustomTabsService(context); - assertThat(result).isFalse(); - assertThat(controller.isCustomTabsServiceBound()).isFalse(); - - mocked.verify( - () -> CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) - ); - } - } - - @Test - public void unbindCustomTabsService_whenBound_unbindsService() { - try (MockedStatic mocked = mockStatic(CustomTabsClient.class)) { - mocked.when(() -> CustomTabsClient.bindCustomTabsService(any(), anyString(), any())) - .thenReturn(true); - - final Context context = mock(Context.class); - controller.bindCustomTabsService(context); - controller.unbindCustomTabsService(); - - assertThat(controller.isCustomTabsServiceBound()).isFalse(); - assertThat(controller.getSession()).isNull(); - verify(context).unbindService(controller); - } - } - - @Test - public void onCustomTabsServiceConnected_setsSession() { - final CustomTabsClient client = mock(CustomTabsClient.class); - final CustomTabsSession session = mock(CustomTabsSession.class); - when(client.newSession(any())).thenReturn(session); - - final ComponentName name = new ComponentName("com.example", "CustomTabsService"); - controller.onCustomTabsServiceConnected(name, client); - - assertNotNull(controller.getSession()); - } - - @Test - public void onServiceDisconnected_clearsSession() { - final ComponentName name = new ComponentName("com.example", "CustomTabsService"); - controller.onServiceDisconnected(name); - - assertThat(controller.getSession()).isNull(); - assertThat(controller.isCustomTabsServiceBound()).isFalse(); - } - - @Test - public void mayLaunchUrls_withSessionAndSingleUrl_callsMayLaunchUrl() { - final CustomTabsSession session = mock(CustomTabsSession.class); - controller.setSession(session); - - when(session.mayLaunchUrl(any(), any(), any())) - .thenReturn(true); - - final String url = "https://example.com"; - controller.mayLaunchUrls(Collections.singletonList(url)); - - verify(session).mayLaunchUrl(eq(Uri.parse(url)), isNull(), isNull()); - } - - @SuppressWarnings("unchecked") - @Test - public void mayLaunchUrls_withSessionAndMultipleUrls_callsMayLaunchUrlWithBundles() { - final CustomTabsSession session = mock(CustomTabsSession.class); - controller.setSession(session); - when(session.mayLaunchUrl(any(), any(), any())).thenReturn(true); - - final String url1 = "https://example.com"; - final String url2 = "https://flutter.dev"; - controller.mayLaunchUrls(Arrays.asList(url1, url2)); - - final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(session).mayLaunchUrl( - isNull(), - isNull(), - captor.capture() - ); - final List bundles = captor.getValue(); - assertThat(bundles).hasSize(2); - - final Bundle bundle1 = bundles.get(0); - assertThat(bundle1).parcelable(KEY_URL).isEqualTo(Uri.parse(url1)); - - final Bundle bundle2 = bundles.get(1); - assertThat(bundle2).parcelable(KEY_URL).isEqualTo(Uri.parse(url2)); - } - - @Test - public void mayLaunchUrls_withNullSession_logsWarning() { - controller.setSession(null); - - final String url = "https://example.com"; - controller.mayLaunchUrls(Collections.singletonList(url)); - - // Since session is null, mayLaunchUrl should not be called - // We verify that session remains null and no exception is thrown - assertThat(controller.getSession()).isNull(); - } - - @Test - public void mayLaunchUrls_withEmptyUrlList_logsWarning() { - final CustomTabsSession session = mock(CustomTabsSession.class); - controller.setSession(session); - - controller.mayLaunchUrls(Collections.emptyList()); - - verify(session, never()).mayLaunchUrl(any(), any(), any()); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.java b/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.java deleted file mode 100644 index 81ab1d1e..00000000 --- a/flutter_custom_tabs_android/android/src/test/java/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.github.droibit.flutter.plugins.customtabs.core.session; - -import static com.droibit.android.customtabs.launcher.CustomTabsIntentHelper.getCustomTabsPackage; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyBoolean; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.content.Context; - -import androidx.browser.customtabs.CustomTabsSession; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import com.droibit.android.customtabs.launcher.CustomTabsIntentHelper; -import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider; -import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.MockedStatic; -import org.robolectric.annotation.Config; - -import java.util.HashMap; -import java.util.Map; - -@RunWith(AndroidJUnit4.class) -@Config(manifest = Config.NONE) -public class CustomTabsSessionManagerTest { - private Map cachedSessions; - - private CustomTabsSessionManager factory; - - @Before - public void setUp() { - cachedSessions = new HashMap<>(); - factory = new CustomTabsSessionManager(cachedSessions); - } - - @After - public void tearDown() { - factory = null; - } - - @Test - public void createSessionController_withValidPackage_returnsSessionController() { - try (MockedStatic mocked = mockStatic(CustomTabsIntentHelper.class)) { - final String packageName = "com.example.customtabs"; - mocked.when(() -> getCustomTabsPackage(any(), anyBoolean(), any())) - .thenReturn(packageName); - - final CustomTabsSessionOptions options = mock(CustomTabsSessionOptions.class); - final CustomTabsPackageProvider additionalCustomTabs = mock(CustomTabsPackageProvider.class); - when(options.getAdditionalCustomTabs(any())).thenReturn(additionalCustomTabs); - - final Context context = mock(Context.class); - final CustomTabsSessionController result = factory.createSessionController(context, options); - - assertThat(result).isNotNull(); - assertThat(result.getPackageName()).isEqualTo(packageName); - assertThat(cachedSessions.containsKey(packageName)).isTrue(); - - mocked.verify(() -> getCustomTabsPackage(any(), eq(true), any())); - } - } - - @Test - public void createSessionController_controller_withNullPackage_returnsNull() { - try (MockedStatic mocked = mockStatic(CustomTabsIntentHelper.class)) { - mocked.when(() -> getCustomTabsPackage(any(), anyBoolean(), any())).thenReturn(null); - - final CustomTabsSessionOptions options = mock(CustomTabsSessionOptions.class); - when(options.getPrefersDefaultBrowser()).thenReturn(true); - final CustomTabsPackageProvider additionalCustomTabs = mock(CustomTabsPackageProvider.class); - when(options.getAdditionalCustomTabs(any())).thenReturn(additionalCustomTabs); - - - final Context context = mock(Context.class); - final CustomTabsSessionController result = factory.createSessionController(context, options); - - assertThat(result).isNull(); - mocked.verify(() -> getCustomTabsPackage(any(), eq(false), any())); - } - } - - @Test - public void getSession_withExistingSession_returnsSession() { - final String packageName = "com.example.customtabs"; - final CustomTabsSessionController controller = mock(CustomTabsSessionController.class); - final CustomTabsSession session = mock(CustomTabsSession.class); - when(controller.getSession()).thenReturn(session); - cachedSessions.put(packageName, controller); - - final CustomTabsSession result = factory.getSession(packageName); - assertThat(result).isNotNull(); - assertThat(result).isSameInstanceAs(session); - } - - @Test - public void getSession_withNonExistingSession_returnsNull() { - final CustomTabsSession result = factory.getSession("non.existent.package"); - assertThat(result).isNull(); - } - - @Test - public void invalidateSession_withExistingSession_removesSession() { - final String packageName = "com.example.customtabs"; - final CustomTabsSessionController controller = mock(CustomTabsSessionController.class); - cachedSessions.put(packageName, controller); - - factory.invalidateSession(packageName); - - assertThat(cachedSessions.containsKey(packageName)).isFalse(); - verify(controller).unbindCustomTabsService(); - } - - @Test - public void invalidateSession_withNonExistentPackage_doesNotRemoveSession() { - final String packageName = "com.example.customtabs"; - final CustomTabsSessionController controller = mock(CustomTabsSessionController.class); - cachedSessions.put(packageName, controller); - - factory.invalidateSession("non.existent.package"); - - assertThat(cachedSessions.containsKey(packageName)).isTrue(); - verify(controller, never()).unbindCustomTabsService(); - } - - @Test - public void handleActivityChange_withNullActivity_unbindsAllServices() { - final String packageName1 = "com.example.customtabs1"; - final CustomTabsSessionController controller1 = mock(CustomTabsSessionController.class); - cachedSessions.put(packageName1, controller1); - - final String packageName2 = "com.example.customtabs2"; - final CustomTabsSessionController controller2 = mock(CustomTabsSessionController.class); - cachedSessions.put(packageName2, controller2); - - factory.handleActivityChange(null); - - verify(controller1).unbindCustomTabsService(); - verify(controller2).unbindCustomTabsService(); - } - - @Test - public void handleActivityChange_withActivity_bindsAllServices() { - final String packageName1 = "com.example.customtabs1"; - final CustomTabsSessionController controller1 = mock(CustomTabsSessionController.class); - - cachedSessions.put(packageName1, controller1); - - final String packageName2 = "com.example.customtabs2"; - final CustomTabsSessionController controller2 = mock(CustomTabsSessionController.class); - cachedSessions.put(packageName2, controller2); - - final Context activity = mock(Context.class); - factory.handleActivityChange(activity); - - verify(controller1).bindCustomTabsService(same(activity)); - verify(controller2).bindCustomTabsService(same(activity)); - } -} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.kt new file mode 100644 index 00000000..30a1c4c8 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/CustomTabsLauncherTest.kt @@ -0,0 +1,350 @@ +package com.github.droibit.flutter.plugins.customtabs + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.droibit.flutter.plugins.customtabs.core.CustomTabsIntentFactory +import com.github.droibit.flutter.plugins.customtabs.core.ExternalBrowserLauncher +import com.github.droibit.flutter.plugins.customtabs.core.NativeAppLauncher +import com.github.droibit.flutter.plugins.customtabs.core.PartialCustomTabsLauncher +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions +import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionController +import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionManager +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class CustomTabsLauncherTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK(relaxed = true) + private lateinit var customTabsIntentFactory: CustomTabsIntentFactory + + @MockK(relaxed = true) + private lateinit var nativeAppLauncher: NativeAppLauncher + + @MockK(relaxed = true) + private lateinit var externalBrowserLauncher: ExternalBrowserLauncher + + @MockK(relaxed = true) + private lateinit var partialCustomTabsLauncher: PartialCustomTabsLauncher + + @MockK(relaxed = true) + private lateinit var customTabsSessionManager: CustomTabsSessionManager + + @InjectMockKs + private lateinit var launcher: CustomTabsLauncher + + @Test + fun launch_withoutActivity_throwsException() { + launcher.setActivity(null) + + try { + launcher.launch("https://example.com", false, null) + fail("error") + } catch (e: Throwable) { + assertThat(e).isInstanceOf(FlutterError::class.java) + + val actualError = (e as FlutterError) + assertThat(actualError.code).isEqualTo("LAUNCH_ERROR") + } + verify(exactly = 0) { nativeAppLauncher.launch(any(), any()) } + } + + @Test + fun launch_withNativeApp() { + val activity = mockk() + launcher.setActivity(activity) + + every { nativeAppLauncher.launch(any(), any()) } returns true + + val expUrl = "https://example.com".toUri() + launcher.launch(expUrl.toString(), true, null) + + verify { + nativeAppLauncher.launch(refEq(activity), eq(expUrl)) + } + verify(exactly = 0) { + externalBrowserLauncher.launch(any(), any(), any()) + customTabsIntentFactory.createIntent(any(), any(), any()) + } + } + + @Test + fun launch_withExternalBrowser() { + val activity = mockk() + launcher.setActivity(activity) + + every { customTabsIntentFactory.createIntentOptions(any()) } returns null + every { externalBrowserLauncher.launch(any(), any(), any()) } returns true + + val expUrl = "https://example.com".toUri() + launcher.launch(expUrl.toString(), false, null) + + verify { externalBrowserLauncher.launch(refEq(activity), eq(expUrl), isNull()) } + verify(exactly = 0) { customTabsIntentFactory.createIntent(any(), any(), any()) } + } + + @Test + fun launch_withExternalBrowser_throwsException() { + val activity = mockk() + launcher.setActivity(activity) + + every { customTabsIntentFactory.createIntentOptions(any()) } returns null + + val anf = ActivityNotFoundException() + every { externalBrowserLauncher.launch(any(), any(), any()) } throws anf + + val url = "https://example.com".toUri() + try { + launcher.launch(url.toString(), false, null) + fail("error") + } catch (e: Throwable) { + assertThat(e).isInstanceOf(FlutterError::class.java) + + val actualError = (e as FlutterError) + assertThat(actualError.code).isEqualTo("LAUNCH_ERROR") + } + + verify { externalBrowserLauncher.launch(refEq(activity), eq(url), isNull()) } + verify(exactly = 0) { customTabsIntentFactory.createIntent(any(), any(), any()) } + } + + @Test + fun launch_withPartialCustomTabs() { + val activity = mockk() + launcher.setActivity(activity) + + val intentOptions = mockk() + every { customTabsIntentFactory.createIntentOptions(any()) } returns intentOptions + every { externalBrowserLauncher.launch(any(), any(), any()) } returns false + + val customTabsIntent = spyk( + CustomTabsIntent.Builder() + .setInitialActivityHeightPx(100) + .build() + ) + every { customTabsIntentFactory.createIntent(any(), any(), any()) } returns customTabsIntent + every { partialCustomTabsLauncher.launch(any(), any(), any()) } returns true + + val expUrl = "https://example.com".toUri() + val options = emptyMap() + launcher.launch(expUrl.toString(), false, options) + + verify { + customTabsIntentFactory.createIntent(refEq(activity), refEq(intentOptions), any()) + partialCustomTabsLauncher.launch( + refEq(activity), + eq(expUrl), + refEq(customTabsIntent) + ) + } + verify(exactly = 0) { customTabsIntent.launchUrl(any(), any()) } + } + + @Test + fun launch_withPartialCustomTabs_throwsException() { + val activity = mockk() + launcher.setActivity(activity) + + val intentOptions = mockk() + every { customTabsIntentFactory.createIntentOptions(any()) } returns intentOptions + every { externalBrowserLauncher.launch(any(), any(), any()) } returns false + + val customTabsIntent = CustomTabsIntent.Builder() + .setInitialActivityHeightPx(100) + .build() + every { customTabsIntentFactory.createIntent(any(), any(), any()) } returns customTabsIntent + + val anf = ActivityNotFoundException() + every { partialCustomTabsLauncher.launch(any(), any(), any()) } throws anf + + try { + val url = "https://example.com".toUri() + val options = emptyMap() + launcher.launch(url.toString(), false, options) + fail("error") + } catch (e: Throwable) { + assertThat(e).isInstanceOf(FlutterError::class.java) + + val actualError = (e as FlutterError) + assertThat(actualError.code).isEqualTo("LAUNCH_ERROR") + } + + verify { + customTabsIntentFactory.createIntent(refEq(activity), refEq(intentOptions), any()) + partialCustomTabsLauncher.launch(refEq(activity), any(), refEq(customTabsIntent)) + } + } + + @Test + fun launch_withCustomTabs() { + val activity = mockk(relaxed = true) + launcher.setActivity(activity) + + val intentOptions = mockk() + every { customTabsIntentFactory.createIntentOptions(any()) } returns intentOptions + every { externalBrowserLauncher.launch(any(), any(), any()) } returns false + + val customTabsIntent = mockk(relaxed = true) + every { customTabsIntentFactory.createIntent(any(), any(), any()) } returns customTabsIntent + every { partialCustomTabsLauncher.launch(any(), any(), any()) } returns false + + val expUrl = "https://example.com".toUri() + val options = emptyMap() + launcher.launch(expUrl.toString(), false, options) + + val urlSlot = slot() + verify { customTabsIntent.launchUrl(refEq(activity), capture(urlSlot)) } + + val actualUrl = urlSlot.captured + assertThat(actualUrl).isEqualTo(expUrl) + + verify { + customTabsIntentFactory.createIntent( + refEq(activity), + refEq(intentOptions), + any() + ) + } + } + + @Test + fun launch_withCustomTabs_throwsException() { + val activity = mockk() + launcher.setActivity(activity) + + val intentOptions = mockk() + every { customTabsIntentFactory.createIntentOptions(any()) } returns intentOptions + every { externalBrowserLauncher.launch(any(), any(), any()) } returns false + + val anf = ActivityNotFoundException() + val customTabsIntent = spyk(CustomTabsIntent.Builder().build()) { + every { launchUrl(any(), any()) } throws anf + } + every { customTabsIntentFactory.createIntent(any(), any(), any()) } returns customTabsIntent + every { partialCustomTabsLauncher.launch(any(), any(), any()) } returns false + + try { + val url = "https://example.com".toUri() + val options = emptyMap() + launcher.launch(url.toString(), false, options) + fail("error") + } catch (e: Throwable) { + assertThat(e).isInstanceOf(FlutterError::class.java) + + val actualError = (e as FlutterError) + assertThat(actualError.code).isEqualTo("LAUNCH_ERROR") + } + + verify { customTabsIntentFactory.createIntent(any(), refEq(intentOptions), any()) } + } + + @Test + fun warmup_withSessionOptions_returnsPackageName() { + val activity = mockk() + launcher.setActivity(activity) + + val sessionOptions = mockk() + every { customTabsSessionManager.createSessionOptions(any()) } returns sessionOptions + + val expPackageName = "com.example.browser" + val controller = mockk { + every { packageName } returns expPackageName + every { bindCustomTabsService(any()) } returns true + } + every { customTabsSessionManager.createSessionController(any(), any()) } returns controller + + val options = emptyMap() + val actualPackageName = launcher.warmup(options) + assertThat(actualPackageName).isEqualTo(expPackageName) + + verify { + customTabsSessionManager.createSessionOptions(refEq(options)) + controller.bindCustomTabsService(any()) + } + } + + @Test + fun warmup_withSessionOptions_returnsNull() { + val activity = mockk() + launcher.setActivity(activity) + + val sessionOptions = mockk() + with(customTabsSessionManager) { + every { createSessionOptions(any()) } returns sessionOptions + every { createSessionController(any(), any()) } returns null + } + + val options = emptyMap() + val actualPackageName = launcher.warmup(options) + assertThat(actualPackageName).isNull() + + verify { customTabsSessionManager.createSessionOptions(refEq(options)) } + } + + @Test + fun warmup_withSessionOptions_bindCustomTabsServiceReturnsFalse() { + val activity = mockk() + launcher.setActivity(activity) + + val sessionOptions = mockk() + every { customTabsSessionManager.createSessionOptions(any()) } returns sessionOptions + + val controller = mockk { + every { bindCustomTabsService(any()) } returns false + } + every { customTabsSessionManager.createSessionController(any(), any()) } returns controller + + val options = emptyMap() + val actualPackageName = launcher.warmup(options) + assertThat(actualPackageName).isNull() + + verify { + customTabsSessionManager.createSessionOptions(refEq(options)) + controller.bindCustomTabsService(any()) + } + } + + @Test + fun warmup_withoutActivity_returnsNull() { + launcher.setActivity(null) + + val actualPackageName = launcher.warmup(emptyMap()) + assertThat(actualPackageName).isNull() + + with(customTabsSessionManager) { + verify(exactly = 0) { + createSessionOptions(any()) + createSessionController(any(), any()) + } + } + } + + @Test + @Throws(Exception::class) + fun invalidate_withValidPackageName_invokesInvalidateSession() { + val packageName = "com.example.browser" + launcher.invalidate(packageName) + + verify { customTabsSessionManager.invalidateSession(packageName) } + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.kt new file mode 100644 index 00000000..7e79d57d --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/CustomTabsIntentFactoryTest.kt @@ -0,0 +1,566 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.os.Parcelable +import android.provider.Browser.EXTRA_HEADERS +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT +import androidx.browser.customtabs.CustomTabsIntent.ACTIVITY_HEIGHT_FIXED +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_CLOSE_BUTTON_ICON +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_CLOSE_BUTTON_POSITION +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_INSTANT_APPS +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_URLBAR_HIDING +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_SHARE_STATE +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE +import androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP +import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF +import androidx.browser.customtabs.CustomTabsIntent.SHOW_PAGE_TITLE +import androidx.core.content.res.ResourcesCompat.ID_NULL +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.truth.content.IntentSubject.assertThat +import androidx.test.ext.truth.os.BundleSubject.assertThat +import com.droibit.android.customtabs.launcher.NonChromeCustomTabs +import com.droibit.android.customtabs.launcher.setChromeCustomTabsPackage +import com.droibit.android.customtabs.launcher.setCustomTabsPackage +import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsAnimations +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsCloseButton +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsColorSchemes +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions +import com.github.droibit.flutter.plugins.customtabs.core.options.PartialCustomTabsConfiguration +import com.github.droibit.flutter.plugins.customtabs.core.session.CustomTabsSessionProvider +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class CustomTabsIntentFactoryTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK + private lateinit var resources: ResourceFactory + + @MockK(relaxed = true) + private lateinit var sessionProvider: CustomTabsSessionProvider + + @MockK + private lateinit var context: Context + + @InjectMockKs + private lateinit var factory: CustomTabsIntentFactory + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun createIntent_completeOptions() { + val expColorSchemes = mockk() + val expUrlBarHidingEnabled = true + val expShareState = SHARE_STATE_OFF + val expShowTitle = true + val expInstantAppsEnabled = false + val expCloseButton = mockk() + val expAnimations = mockk() + val expPartial = mockk() + val expBrowser = mockk(relaxed = true) + val options = CustomTabsIntentOptions.Builder() + .setColorSchemes(expColorSchemes) + .setCloseButton(expCloseButton) + .setUrlBarHidingEnabled(expUrlBarHidingEnabled) + .setShareState(expShareState) + .setShowTitle(expShowTitle) + .setInstantAppsEnabled(expInstantAppsEnabled) + .setAnimations(expAnimations) + .setPartial(expPartial) + .setBrowser(expBrowser) + .build() + + val factory = spyk(this.factory) { + justRun { applyColorSchemes(any(), any()) } + justRun { applyCloseButton(any(), any(), any()) } + justRun { applyAnimations(any(), any(), any()) } + justRun { applyPartialCustomTabsConfiguration(any(), any(), any()) } + justRun { applyBrowserConfiguration(any(), any(), any()) } + } + + val customTabsIntent = factory.createIntent(context, options, sessionProvider) + val extras = assertThat(customTabsIntent.intent).extras() + + val colorSchemesSlot = slot() + verify { factory.applyColorSchemes(any(), capture(colorSchemesSlot)) } + + assertThat(colorSchemesSlot.captured).isSameInstanceAs(expColorSchemes) + + val closeButtonSlot = slot() + verify { factory.applyCloseButton(any(), any(), capture(closeButtonSlot)) } + assertThat(closeButtonSlot.captured).isSameInstanceAs(expCloseButton) + + extras.bool(EXTRA_ENABLE_URLBAR_HIDING).isEqualTo(expUrlBarHidingEnabled) + extras.integer(EXTRA_SHARE_STATE).isEqualTo(expShareState) + extras.integer(EXTRA_TITLE_VISIBILITY_STATE).isEqualTo(SHOW_PAGE_TITLE) + extras.bool(EXTRA_ENABLE_INSTANT_APPS).isEqualTo(expInstantAppsEnabled) + + val animationsSlot = slot() + verify { factory.applyAnimations(any(), any(), capture(animationsSlot)) } + assertThat(animationsSlot.captured).isSameInstanceAs(expAnimations) + + val partialSlot = slot() + verify { factory.applyPartialCustomTabsConfiguration(any(), any(), capture(partialSlot)) } + assertThat(partialSlot.captured).isSameInstanceAs(expPartial) + + val browserSlot = slot() + verify { factory.applyBrowserConfiguration(any(), any(), capture(browserSlot)) } + assertThat(browserSlot.captured).isSameInstanceAs(expBrowser) + } + + @Test + fun createIntent_minimumOptions() { + val options = CustomTabsIntentOptions.Builder() + .build() + val factory = spyk(this.factory) { + justRun { applyColorSchemes(any(), any()) } + justRun { applyCloseButton(any(), any(), any()) } + justRun { applyAnimations(any(), any(), any()) } + justRun { applyPartialCustomTabsConfiguration(any(), any(), any()) } + justRun { applyBrowserConfiguration(any(), any(), any()) } + } + + val customTabsIntent = factory.createIntent(context, options, sessionProvider) + val extras = assertThat(customTabsIntent.intent).extras() + extras.doesNotContainKey(EXTRA_ENABLE_URLBAR_HIDING) + extras.doesNotContainKey(EXTRA_TITLE_VISIBILITY_STATE) + // It seems that CustomTabsIntent includes these extras by default. + extras.containsKey(EXTRA_SHARE_STATE) + extras.containsKey(EXTRA_ENABLE_INSTANT_APPS) + + verify(exactly = 0) { + factory.applyColorSchemes(any(), any()) + factory.applyCloseButton(any(), any(), any()) + factory.applyAnimations(any(), any(), any()) + factory.applyPartialCustomTabsConfiguration(any(), any(), any()) + } + + val browserConfigSlot = slot() + verify { factory.applyBrowserConfiguration(any(), any(), capture(browserConfigSlot)) } + + val actualBrowserConfig = browserConfigSlot.captured + assertThat(actualBrowserConfig.prefersExternalBrowser).isNull() + assertThat(actualBrowserConfig.prefersDefaultBrowser).isNull() + assertThat(actualBrowserConfig.fallbackCustomTabPackages).isNull() + assertThat(actualBrowserConfig.headers).isNull() + } + + /** + * @noinspection DataFlowIssue + */ + @Test + fun applyColorSchemes_completeOptions() { + val expLightParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(0xFFFFDBA0.toInt()) + .setNavigationBarDividerColor(0xFFFFDBA1.toInt()) + .setNavigationBarColor(0xFFFFDBA2.toInt()) + .build() + val expDarkParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(0xFFFFDBA3.toInt()) + .setNavigationBarDividerColor(0xFFFFDBA4.toInt()) + .setNavigationBarColor(0xFFFFDBA5.toInt()) + .build() + val expDefaultParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(0xFFFFDBA5.toInt()) + .setNavigationBarDividerColor(0xFFFFDBA6.toInt()) + .setNavigationBarColor(0xFFFFDBA7.toInt()) + .build() + val expColorScheme = CustomTabsIntent.COLOR_SCHEME_SYSTEM + val options = CustomTabsColorSchemes.Builder() + .setColorScheme(expColorScheme) + .setLightParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(expLightParams.toolbarColor ?: 0) + .setNavigationBarColor(expLightParams.navigationBarColor ?: 0) + .setNavigationBarDividerColor(expLightParams.navigationBarDividerColor ?: 0) + .build() + ) + .setDarkParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(expDarkParams.toolbarColor ?: 0) + .setNavigationBarColor(expDarkParams.navigationBarColor ?: 0) + .setNavigationBarDividerColor(expDarkParams.navigationBarDividerColor ?: 0) + .build() + ) + .setDefaultParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(expDefaultParams.toolbarColor ?: 0) + .setNavigationBarColor(expDefaultParams.navigationBarColor ?: 0) + .setNavigationBarDividerColor(expDefaultParams.navigationBarDividerColor ?: 0) + .build() + ) + .build() + val builder = mockk(relaxed = true) + factory.applyColorSchemes(builder, options) + + val schemeSlot = mutableListOf() + val paramsSlot = mutableListOf() + verify { builder.setColorScheme(capture(schemeSlot)) } + verify(exactly = 2) { + builder.setColorSchemeParams(capture(schemeSlot), capture(paramsSlot)) + } + verify { builder.setDefaultColorSchemeParams(capture(paramsSlot)) } + + assertThat(schemeSlot).containsExactly( + expColorScheme, + CustomTabsIntent.COLOR_SCHEME_LIGHT, + CustomTabsIntent.COLOR_SCHEME_DARK + ) + assertThat(paramsSlot).hasSize(3) + + val actualLightParams = paramsSlot[0] + assertThat(actualLightParams.toolbarColor).isEqualTo(expLightParams.toolbarColor) + assertThat(actualLightParams.navigationBarColor) + .isEqualTo(expLightParams.navigationBarColor) + assertThat(actualLightParams.navigationBarDividerColor) + .isEqualTo(expLightParams.navigationBarDividerColor) + + val actualDarkParams = paramsSlot[1] + assertThat(actualDarkParams.toolbarColor).isEqualTo(expDarkParams.toolbarColor) + assertThat(actualDarkParams.navigationBarColor) + .isEqualTo(expDarkParams.navigationBarColor) + assertThat(actualDarkParams.navigationBarDividerColor) + .isEqualTo(expDarkParams.navigationBarDividerColor) + + val actualDefaultParams = paramsSlot[2] + assertThat(actualDefaultParams.toolbarColor) + .isEqualTo(expDefaultParams.toolbarColor) + assertThat(actualDefaultParams.navigationBarColor) + .isEqualTo(expDefaultParams.navigationBarColor) + assertThat(actualDefaultParams.navigationBarDividerColor) + .isEqualTo(expDefaultParams.navigationBarDividerColor) + } + + @Test + fun applyColorSchemes_minimumOptions() { + val options = CustomTabsColorSchemes.Builder().build() + val builder = mockk() + factory.applyColorSchemes(builder, options) + + verify(exactly = 0) { + builder.setColorScheme(any()) + builder.setColorSchemeParams(any(), any()) + builder.setDefaultColorSchemeParams(any()) + } + } + + @Test + fun applyCloseButton_completeOptions() { + val expIcon = mockk() + every { resources.getBitmap(any(), any()) } returns expIcon + + val expPosition = CustomTabsIntent.CLOSE_BUTTON_POSITION_DEFAULT + val options = CustomTabsCloseButton.Builder() + .setIcon("icon") + .setPosition(expPosition) + .build() + + val builder = CustomTabsIntent.Builder() + factory.applyCloseButton(context, builder, options) + + val customTabsIntent = builder.build() + val extras = assertThat(customTabsIntent.intent).extras() + extras.isNotNull() + extras.parcelable(EXTRA_CLOSE_BUTTON_ICON).isSameInstanceAs(expIcon) + extras.integer(EXTRA_CLOSE_BUTTON_POSITION).isEqualTo(expPosition) + } + + @Test + fun applyCloseButton_minimumOptions() { + val options = CustomTabsCloseButton.Builder().build() + val builder = CustomTabsIntent.Builder() + factory.applyCloseButton(context, builder, options) + + val customTabsIntent = builder.build() + val extras = assertThat(customTabsIntent.intent).extras() + extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_ICON) + extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_POSITION) + } + + @Test + fun applyCloseButton_invalidIcon() { + every { resources.getBitmap(any(), any()) } returns null + + val options = CustomTabsCloseButton.Builder() + .setIcon("icon") + .build() + val builder = CustomTabsIntent.Builder() + factory.applyCloseButton(context, builder, options) + + val customTabsIntent = builder.build() + val extras = assertThat(customTabsIntent.intent).extras() + extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_ICON) + extras.doesNotContainKey(EXTRA_CLOSE_BUTTON_POSITION) + } + + @Test + fun applyAnimations_completeOptions() { + val expStartEnter = 1 + val expStartExit = 2 + val expEndEnter = 3 + val expEndExit = 4 + every { resources.getAnimationIdentifier(any(), eq("start_enter")) } returns expStartEnter + every { resources.getAnimationIdentifier(any(), eq("start_exit")) } returns expStartExit + every { resources.getAnimationIdentifier(any(), eq("end_enter")) } returns expEndEnter + every { resources.getAnimationIdentifier(any(), eq("end_exit")) } returns expEndExit + + val options = CustomTabsAnimations.Builder() + .setStartEnter("start_enter") + .setStartExit("start_exit") + .setEndEnter("end_enter") + .setEndExit("end_exit") + .build() + val builder = mockk(relaxed = true) + factory.applyAnimations(context, builder, options) + + val startEnterSlot = slot() + val startExitSlot = slot() + verify { + builder.setStartAnimations( + any(), + capture(startEnterSlot), + capture(startExitSlot) + ) + } + assertThat(startEnterSlot.captured).isEqualTo(expStartEnter) + assertThat(startExitSlot.captured).isEqualTo(expStartExit) + + val endEnterSlot = slot() + val endExitSlot = slot() + verify { builder.setExitAnimations(any(), capture(endEnterSlot), capture(endExitSlot)) } + assertThat(endEnterSlot.captured).isEqualTo(expEndEnter) + assertThat(endExitSlot.captured).isEqualTo(expEndExit) + } + + @Test + fun applyAnimations_emptyOptions() { + every { resources.getAnimationIdentifier(any(), any()) } returns ID_NULL + + + val options = CustomTabsAnimations.Builder().build() + val builder = mockk() + factory.applyAnimations(context, builder, options) + + verify(exactly = 0) { + builder.setStartAnimations(any(), any(), any()) + builder.setExitAnimations(any(), any(), any()) + } + } + + @Test + fun applyPartialCustomTabsConfiguration_completeOptions() { + val expCornerRadius = 8 + val options = PartialCustomTabsConfiguration.Builder() + .setActivityHeightResizeBehavior(ACTIVITY_HEIGHT_FIXED) + .setInitialHeight(100.0) + .setCornerRadius(expCornerRadius) + .build() + + val expInitialActivityHeight = 100 + every { resources.convertToPx(any(), any()) } returns expInitialActivityHeight + + val builder = CustomTabsIntent.Builder() + factory.applyPartialCustomTabsConfiguration(context, builder, options) + + val customTabsIntent = builder.build() + val extras = assertThat(customTabsIntent.intent).extras() + extras.integer(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR).isEqualTo(ACTIVITY_HEIGHT_FIXED) + extras.integer(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX).isEqualTo(expInitialActivityHeight) + extras.integer(EXTRA_TOOLBAR_CORNER_RADIUS_DP).isEqualTo(expCornerRadius) + } + + @Test + fun applyPartialCustomTabsConfiguration_minimumOptions() { + val options = PartialCustomTabsConfiguration.Builder() + .setInitialHeight(200.0) + .build() + + val expInitialActivityHeight = 200 + every { resources.convertToPx(any(), any()) } returns expInitialActivityHeight + + val builder = CustomTabsIntent.Builder() + factory.applyPartialCustomTabsConfiguration(context, builder, options) + + val customTabsIntent = builder.build() + val extras = assertThat(customTabsIntent.intent).extras() + extras.integer(EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR).isEqualTo(ACTIVITY_HEIGHT_DEFAULT) + extras.integer(EXTRA_INITIAL_ACTIVITY_HEIGHT_PX).isEqualTo(expInitialActivityHeight) + extras.doesNotContainKey(EXTRA_TOOLBAR_CORNER_RADIUS_DP) + } + + @Test + fun applyBrowserConfiguration_completeOptionsWithPrefersChrome() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val customTabsIntent = spyk(CustomTabsIntent.Builder().build()) + every { + customTabsIntent.setChromeCustomTabsPackage(any(), any()) + } returns customTabsIntent + + val expHeader = "key" to "value" + val options = BrowserConfiguration.Builder() + .setHeaders(mapOf(expHeader)) + .setFallbackCustomTabs(setOf("com.example.customtabs")) + .setPrefersExternalBrowser(false) + .setPrefersDefaultBrowser(false) + .build() + factory.applyBrowserConfiguration(context, customTabsIntent, options) + + assertThat(customTabsIntent.intent).extras().containsKey(EXTRA_HEADERS) + val actualHeaders = customTabsIntent.intent.getBundleExtra(EXTRA_HEADERS) + assertThat(actualHeaders).isNotNull() + assertThat(actualHeaders).hasSize(1) + assertThat(actualHeaders).string(expHeader.first).isEqualTo(expHeader.second) + + verify { + customTabsIntent.setChromeCustomTabsPackage( + any(), + ofType(NonChromeCustomTabs::class) + ) + } + } + + @Suppress("deprecation") + @Test + fun applyBrowserConfiguration_minimumOptionsWithPrefersChrome() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val customTabsIntent = spyk(CustomTabsIntent.Builder().build()) + every { + customTabsIntent.setChromeCustomTabsPackage(any(), any()) + } returns customTabsIntent + + val pm = mockk { + every { queryIntentActivities(any(), any()) } returns emptyList() + } + every { context.packageManager } returns pm + + val options = BrowserConfiguration.Builder() + .setPrefersExternalBrowser(false) + .build() + factory.applyBrowserConfiguration(context, customTabsIntent, options) + + assertThat(customTabsIntent.intent) + .extras() + .doesNotContainKey(EXTRA_HEADERS) + + verify { + customTabsIntent.setChromeCustomTabsPackage( + any(), + ofType(NonChromeCustomTabs::class) + ) + } + } + + @Suppress("deprecation") + @Test + fun applyBrowserConfiguration_prefersDefaultBrowser() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val customTabsIntent = spyk(CustomTabsIntent.Builder().build()) + every { customTabsIntent.setCustomTabsPackage(any(), any()) } returns customTabsIntent + + val pm = mockk { + every { queryIntentActivities(any(), any()) } returns emptyList() + } + every { context.packageManager } returns pm + + val options = BrowserConfiguration.Builder() + .setPrefersExternalBrowser(false) + .setPrefersDefaultBrowser(true) + .build() + factory.applyBrowserConfiguration(context, customTabsIntent, options) + + assertThat(customTabsIntent.intent) + .extras() + .doesNotContainKey(EXTRA_HEADERS) + + verify { + customTabsIntent.setCustomTabsPackage( + any(), + ofType(NonChromeCustomTabs::class) + ) + } + } + + @Test + fun applyBrowserConfiguration_customTabsSession() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val expSessionPackageName = "com.example.customtabs" + val options = BrowserConfiguration.Builder() + .setSessionPackageName(expSessionPackageName) + .build() + val customTabsIntent = CustomTabsIntent.Builder().build() + factory.applyBrowserConfiguration(context, customTabsIntent, options) + + assertThat(customTabsIntent.intent).hasPackage(expSessionPackageName) + + verify(exactly = 0) { + customTabsIntent.setChromeCustomTabsPackage(any(), any()) + customTabsIntent.setCustomTabsPackage(any(), any()) + } + } + + @Test + fun applyBrowserConfiguration_avoidOverridingPackage() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val customTabsIntent = CustomTabsIntent.Builder().build() + val expPackageName = "com.example.customtabs" + customTabsIntent.intent.setPackage(expPackageName) + + val sessionPackageName = "com.example.session.customtabs" + val options = BrowserConfiguration.Builder() + .setSessionPackageName(sessionPackageName) + .build() + factory.applyBrowserConfiguration(context, customTabsIntent, options) + + assertThat(customTabsIntent.intent).hasPackage(expPackageName) + + verify(exactly = 0) { + customTabsIntent.setChromeCustomTabsPackage(any(), any()) + customTabsIntent.setCustomTabsPackage(any(), any()) + } + } + + @Test + fun createIntentOptions_nullOptions() { + val options = factory.createIntentOptions(null) + assertThat(options).isNull() + } + + @Test + fun createIntentOptions_notNullOptions() { + val options = factory.createIntentOptions(emptyMap()) + assertThat(options).isNotNull() + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.kt new file mode 100644 index 00000000..ff8f9d24 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/ExternalBrowserLauncherTest.kt @@ -0,0 +1,144 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Browser +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.truth.content.IntentSubject.assertThat +import androidx.test.ext.truth.os.BundleSubject.assertThat +import com.github.droibit.flutter.plugins.customtabs.core.options.BrowserConfiguration +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsIntentOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.spyk +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class ExternalBrowserLauncherTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK(relaxed = true) + private lateinit var context: Context + + @InjectMockKs + private lateinit var launcher: ExternalBrowserLauncher + + @Test + fun launch_withNullIntent_returnsFalse() { + val launcher = spyk(this.launcher) { + every { createIntent(any()) } returns null + } + + val uri = "https://example.com".toUri() + val result = launcher.launch(context, uri, null) + assertThat(result).isFalse() + + verify(exactly = 0) { context.startActivity(any()) } + } + + @Test + fun launch_withValidIntent_returnsTrue() { + val intent = Intent() + val launcher = spyk(this.launcher) { + every { createIntent(any()) } returns intent + } + + val uri = Uri.parse("https://example.com") + val result = launcher.launch(context, uri, null) + assertThat(result).isTrue() + + assertThat(intent).hasData(uri) + verify { context.startActivity(refEq(intent)) } + } + + @Test + fun createIntent_nullOptions() { + val result = launcher.createIntent(null) + assertThat(result).isNotNull() + assertThat(result).hasAction(Intent.ACTION_VIEW) + assertThat(result).extras().isNull() + } + + @Test + fun createIntent_emptyBrowserConfiguration() { + val options = CustomTabsIntentOptions.Builder() + .setBrowser(null) + .build() + val result = launcher.createIntent(options) + assertThat(result).isNull() + } + + @Test + fun createIntent_noPriority() { + val options = CustomTabsIntentOptions.Builder() + .setBrowser( + BrowserConfiguration.Builder() + .setPrefersExternalBrowser(null) + .build() + ) + .build() + val result = launcher.createIntent(options) + assertThat(result).isNull() + } + + @Test + fun createIntent_prefersCustomTabs() { + val options = CustomTabsIntentOptions.Builder() + .setBrowser( + BrowserConfiguration.Builder() + .setPrefersExternalBrowser(false) + .build() + ) + .build() + val result = launcher.createIntent(options) + assertThat(result).isNull() + } + + @Test + fun createIntent_noHeaders() { + val options = CustomTabsIntentOptions.Builder() + .setBrowser( + BrowserConfiguration.Builder() + .setPrefersExternalBrowser(true) + .build() + ) + .build() + val result = launcher.createIntent(options) + assertThat(result).isNotNull() + assertThat(result).hasAction(Intent.ACTION_VIEW) + assertThat(result).extras().isNull() + } + + @Test + fun createIntent_addedHeaders() { + val expHeader = "key" to "value" + val options = CustomTabsIntentOptions.Builder() + .setBrowser( + BrowserConfiguration.Builder() + .setPrefersExternalBrowser(true) + .setHeaders(mapOf(expHeader)) + .build() + ) + .build() + val result = launcher.createIntent(options) + assertThat(result).isNotNull() + assertThat(result).hasAction(Intent.ACTION_VIEW) + assertThat(result).extras().hasSize(1) + + val actualHeaders = requireNotNull(result).getBundleExtra(Browser.EXTRA_HEADERS) + assertThat(actualHeaders).isNotNull() + assertThat(actualHeaders).hasSize(1) + assertThat(actualHeaders).string(expHeader.first).isEqualTo(expHeader.second) + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.kt new file mode 100644 index 00000000..56ffb27b --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/PartialCustomTabsLauncherTest.kt @@ -0,0 +1,54 @@ +package com.github.droibit.flutter.plugins.customtabs.core + +import android.app.Activity +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.truth.content.IntentSubject.assertThat +import com.google.common.truth.Truth.assertThat +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class PartialCustomTabsLauncherTest { + @get:Rule + val mockkRule = MockKRule(this) + + @MockK(relaxed = true) + private lateinit var activity: Activity + + @InjectMockKs + private lateinit var launcher: PartialCustomTabsLauncher + + @Test + fun launch_withValidCustomTabsIntent_returnsTrue() { + val customTabsIntent = CustomTabsIntent.Builder() + .setInitialActivityHeightPx(100) + .build() + + val uri = "https://example.com".toUri() + val result = launcher.launch(activity, uri, customTabsIntent) + assertThat(result).isTrue() + assertThat(customTabsIntent.intent).hasData(uri) + + verify { activity.startActivityForResult(refEq(customTabsIntent.intent), eq(1001)) } + } + + @Test + fun launch_withoutRequiredExtras_returnsFalse() { + val customTabsIntent = CustomTabsIntent.Builder().build() + + val uri = "https://example.com".toUri() + val result = launcher.launch(activity, uri, customTabsIntent) + assertThat(result).isFalse() + + verify(exactly = 0) { activity.startActivityForResult(any(), any()) } + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.kt new file mode 100644 index 00000000..26b7bf88 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionControllerTest.kt @@ -0,0 +1,178 @@ +package com.github.droibit.flutter.plugins.customtabs.core.session + +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsService.KEY_URL +import androidx.browser.customtabs.CustomTabsSession +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.truth.os.BundleSubject.assertThat +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class CustomTabsSessionControllerTest { + private val packageName = "com.example.browser" + + private lateinit var controller: CustomTabsSessionController + + @Before + fun setUp() { + controller = CustomTabsSessionController(packageName) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun bindCustomTabsService_withValidPackageName_returnsTrue() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.bindCustomTabsService(any(), any(), any()) } returns true + + val context = mockk() + val result = controller.bindCustomTabsService(context) + assertThat(result).isTrue() + assertThat(controller.isCustomTabsServiceBound).isTrue() + + verify { CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) } + } + + @Test + fun bindCustomTabsService_withInvalidPackageName_returnsFalse() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.bindCustomTabsService(any(), any(), any()) } returns false + + val context = mockk() + val result = controller.bindCustomTabsService(context) + assertThat(result).isFalse() + assertThat(controller.isCustomTabsServiceBound).isFalse() + + verify { CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) } + } + + @Test + fun bindCustomTabsService_withSecurityException_returnsFalse() { + mockkStatic(CustomTabsClient::class) + + val ex = SecurityException() + every { CustomTabsClient.bindCustomTabsService(any(), any(), any()) } throws ex + + val context = mockk() + val result = controller.bindCustomTabsService(context) + assertThat(result).isFalse() + assertThat(controller.isCustomTabsServiceBound).isFalse() + + verify { CustomTabsClient.bindCustomTabsService(any(), eq(packageName), any()) } + } + + @Test + fun unbindCustomTabsService_whenBound_unbindsService() { + mockkStatic(CustomTabsClient::class) + every { CustomTabsClient.bindCustomTabsService(any(), any(), any()) } returns true + + val context = mockk(relaxed = true) + controller.bindCustomTabsService(context) + controller.unbindCustomTabsService() + + assertThat(controller.isCustomTabsServiceBound).isFalse() + assertThat(controller.session).isNull() + + verify { context.unbindService(refEq(controller)) } + } + + @Test + fun onCustomTabsServiceConnected_setsSession() { + val session = mockk() + val client = mockk(relaxed = true) { + every { newSession(any()) } returns session + } + + val name = ComponentName("com.example", "CustomTabsService") + controller.onCustomTabsServiceConnected(name, client) + + assertThat(controller.session).isNotNull() + } + + @Test + fun onServiceDisconnected_clearsSession() { + val name = ComponentName("com.example", "CustomTabsService") + controller.onServiceDisconnected(name) + + assertThat(controller.session).isNull() + assertThat(controller.isCustomTabsServiceBound).isFalse() + } + + @Test + fun mayLaunchUrls_withSessionAndSingleUrl_callsMayLaunchUrl() { + val session = mockk { + every { mayLaunchUrl(any(), any(), any()) } returns true + } + controller.session = session + + val url = "https://example.com".toUri() + controller.mayLaunchUrls(listOf(url.toString())) + + verify { session.mayLaunchUrl(eq(url), isNull(), isNull()) } + } + + @Test + fun mayLaunchUrls_withSessionAndMultipleUrls_callsMayLaunchUrlWithBundles() { + val session = mockk { + every { mayLaunchUrl(any(), any(), any()) } returns true + } + controller.session = session + + val url1 = "https://example.com".toUri() + val url2 = "https://flutter.dev".toUri() + controller.mayLaunchUrls(listOf(url1.toString(), url2.toString())) + + val slot = mutableListOf>() + verify { session.mayLaunchUrl(isNull(), isNull(), capture(slot)) } + + val bundles = slot.first() + assertThat(bundles).hasSize(2) + + val bundle1 = bundles[0] + assertThat(bundle1).parcelable(KEY_URL).isEqualTo(url1) + + val bundle2 = bundles[1] + assertThat(bundle2).parcelable(KEY_URL).isEqualTo(url2) + } + + @Test + fun mayLaunchUrls_withNullSession_logsWarning() { + controller.session = null + + val url = "https://example.com".toUri() + controller.mayLaunchUrls(listOf(url.toString())) + + // Since session is null, mayLaunchUrl should not be called + // We verify that session remains null and no exception is thrown + assertThat(controller.session).isNull() + } + + @Test + fun mayLaunchUrls_withEmptyUrlList_logsWarning() { + val session = mockk() + controller.session = session + + controller.mayLaunchUrls(emptyList()) + + verify(exactly = 0) { session.mayLaunchUrl(any(), any(), any()) } + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.kt b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.kt new file mode 100644 index 00000000..b6c2cab2 --- /dev/null +++ b/flutter_custom_tabs_android/android/src/test/kotlin/com/github/droibit/flutter/plugins/customtabs/core/session/CustomTabsSessionManagerTest.kt @@ -0,0 +1,204 @@ +package com.github.droibit.flutter.plugins.customtabs.core.session + +import android.content.Context +import androidx.browser.customtabs.CustomTabsSession +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.droibit.android.customtabs.launcher.CustomTabsPackageProvider +import com.droibit.android.customtabs.launcher.getCustomTabsPackage +import com.github.droibit.flutter.plugins.customtabs.core.options.CustomTabsSessionOptions +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class CustomTabsSessionManagerTest { + private lateinit var cachedSessions: MutableMap + + private lateinit var factory: CustomTabsSessionManager + + @Before + fun setUp() { + cachedSessions = mutableMapOf() + factory = CustomTabsSessionManager(cachedSessions) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun createSessionController_withValidPackage_returnsSessionController() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val packageName = "com.example.customtabs" + every { getCustomTabsPackage(any(), any(), any()) } returns packageName + + val additionalCustomTabs = mockk() + val options = mockk(relaxed = true) { + every { prefersDefaultBrowser } returns null + every { getAdditionalCustomTabs(any()) } returns additionalCustomTabs + } + + val context = mockk() + val result = factory.createSessionController(context, options) + assertThat(result).isNotNull() + assertThat(requireNotNull(result).packageName).isEqualTo(packageName) + assertThat(cachedSessions.containsKey(packageName)).isTrue() + + verify { getCustomTabsPackage(any(), eq(true), any()) } + } + + @Test + fun createSessionController_withNullPackage_returnsNull() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + every { getCustomTabsPackage(any(), any(), any()) } returns null + + val additionalCustomTabs = mockk() + val options = mockk { + every { prefersDefaultBrowser } returns null + every { getAdditionalCustomTabs(any()) } returns additionalCustomTabs + } + + val context = mockk() + val result = factory.createSessionController(context, options) + assertThat(result).isNull() + + verify { getCustomTabsPackage(any(), eq(true), any()) } + } + + @Test + fun createSessionController_prefersDefaultBrowser_returnsSessionController() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val packageName = "com.example.customtabs" + every { getCustomTabsPackage(any(), any(), any()) } returns packageName + + val additionalCustomTabs = mockk() + val options = mockk(relaxed = true) { + every { prefersDefaultBrowser } returns true + every { getAdditionalCustomTabs(any()) } returns additionalCustomTabs + } + + val context = mockk() + val result = factory.createSessionController(context, options) + assertThat(result).isNotNull() + assertThat(requireNotNull(result).packageName).isEqualTo(packageName) + assertThat(cachedSessions.containsKey(packageName)).isTrue() + + verify { getCustomTabsPackage(any(), eq(false), any()) } + } + + @Test + fun createSessionController_prefersChrome_returnsSessionController() { + mockkStatic("com.droibit.android.customtabs.launcher.CustomTabsIntentHelper") + + val packageName = "com.example.customtabs" + every { getCustomTabsPackage(any(), any(), any()) } returns packageName + + val additionalCustomTabs = mockk() + val options = mockk(relaxed = true) { + every { prefersDefaultBrowser } returns false + every { getAdditionalCustomTabs(any()) } returns additionalCustomTabs + } + + val context = mockk() + val result = factory.createSessionController(context, options) + assertThat(result).isNotNull() + assertThat(requireNotNull(result).packageName).isEqualTo(packageName) + assertThat(cachedSessions.containsKey(packageName)).isTrue() + + verify { getCustomTabsPackage(any(), eq(true), any()) } + } + + @Test + fun session_withExistingSession_returnsSession() { + val packageName = "com.example.customtabs" + val expSession = mockk() + val controller = mockk { + every { session } returns expSession + } + cachedSessions[packageName] = controller + + val result = factory.getSession(packageName) + assertThat(result).isNotNull() + assertThat(result).isSameInstanceAs(expSession) + } + + @Test + fun session_withNonExistingSession_returnsNull() { + val result = factory.getSession("non.existent.package") + assertThat(result).isNull() + } + + @Test + fun invalidateSession_withExistingSession_removesSession() { + val packageName = "com.example.customtabs" + val controller = mockk(relaxed = true) + cachedSessions[packageName] = controller + + factory.invalidateSession(packageName) + + assertThat(cachedSessions.containsKey(packageName)).isFalse() + verify { controller.unbindCustomTabsService() } + } + + @Test + fun invalidateSession_withNonExistentPackage_doesNotRemoveSession() { + val packageName = "com.example.customtabs" + val controller = mockk() + cachedSessions[packageName] = controller + + factory.invalidateSession("non.existent.package") + + assertThat(cachedSessions.containsKey(packageName)).isTrue() + verify(exactly = 0) { controller.unbindCustomTabsService() } + } + + @Test + fun handleActivityChange_withNullActivity_unbindsAllServices() { + val packageName1 = "com.example.customtabs1" + val controller1 = mockk(relaxed = true) + cachedSessions[packageName1] = controller1 + + val packageName2 = "com.example.customtabs2" + val controller2 = mockk(relaxed = true) + cachedSessions[packageName2] = controller2 + + factory.handleActivityChange(null) + + verify { + controller1.unbindCustomTabsService() + controller2.unbindCustomTabsService() + } + } + + @Test + fun handleActivityChange_withActivity_bindsAllServices() { + val packageName1 = "com.example.customtabs1" + val controller1 = mockk(relaxed = true) + + cachedSessions[packageName1] = controller1 + + val packageName2 = "com.example.customtabs2" + val controller2 = mockk(relaxed = true) + cachedSessions[packageName2] = controller2 + + val activity = mockk() + factory.handleActivityChange(activity) + + verify { + controller1.bindCustomTabsService(refEq(activity)) + controller2.bindCustomTabsService(refEq(activity)) + } + } +} \ No newline at end of file diff --git a/flutter_custom_tabs_android/example/android/app/build.gradle b/flutter_custom_tabs_android/example/android/app/build.gradle index dfb04a6d..58a3dd73 100644 --- a/flutter_custom_tabs_android/example/android/app/build.gradle +++ b/flutter_custom_tabs_android/example/android/app/build.gradle @@ -1,6 +1,7 @@ plugins { - id "com.android.application" - id "dev.flutter.flutter-gradle-plugin" + id 'com.android.application' + id 'kotlin-android' + id 'dev.flutter.flutter-gradle-plugin' } def localProperties = new Properties() @@ -22,19 +23,14 @@ if (flutterVersionName == null) { } android { - namespace "com.github.droibit.plugins.flutter.customtabs.android.example" + namespace 'com.github.droibit.plugins.flutter.customtabs.android.example' compileSdk 34 //flutter.compileSdkVersion ndkVersion flutter.ndkVersion - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - defaultConfig { - applicationId "com.github.droibit.plugins.flutter.customtabs.flutter_custom_tabs_android_example" + applicationId 'com.github.droibit.plugins.flutter.customtabs.flutter_custom_tabs_android_example' // Enable multidex support. - minSdk 21 //flutter.minSdkVersion + minSdk flutter.minSdkVersion targetSdk flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -48,6 +44,15 @@ android { signingConfig signingConfigs.debug } } + + kotlinOptions { + jvmTarget = '1.8' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } flutter { diff --git a/flutter_custom_tabs_android/example/android/app/src/main/java/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.java b/flutter_custom_tabs_android/example/android/app/src/main/java/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.java deleted file mode 100644 index 4a806592..00000000 --- a/flutter_custom_tabs_android/example/android/app/src/main/java/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.droibit.plugins.flutter.customtabs.android.example; - -import android.os.Bundle; -import android.util.Log; - -import androidx.annotation.Nullable; - -import io.flutter.embedding.android.FlutterActivity; - -public class MainActivity extends FlutterActivity { - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Log.d("DEBUG", "#onCreate()"); - } -} diff --git a/flutter_custom_tabs_android/example/android/app/src/main/kotlin/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.kt b/flutter_custom_tabs_android/example/android/app/src/main/kotlin/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.kt new file mode 100644 index 00000000..f0593ede --- /dev/null +++ b/flutter_custom_tabs_android/example/android/app/src/main/kotlin/com/github/droibit/plugins/flutter/customtabs/android/example/MainActivity.kt @@ -0,0 +1,12 @@ +package com.github.droibit.plugins.flutter.customtabs.android.example + +import android.os.Bundle +import android.util.Log +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d("DEBUG", "#onCreate()") + } +} diff --git a/flutter_custom_tabs_android/example/android/build.gradle b/flutter_custom_tabs_android/example/android/build.gradle index a1a70a3a..fcf6436d 100644 --- a/flutter_custom_tabs_android/example/android/build.gradle +++ b/flutter_custom_tabs_android/example/android/build.gradle @@ -19,7 +19,7 @@ allprojects { } gradle.projectsEvaluated { - tasks.withType(JavaCompile) { + tasks.withType(JavaCompile).configureEach { options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" } } diff --git a/flutter_custom_tabs_android/pigeons/messages.dart b/flutter_custom_tabs_android/pigeons/messages.dart index 0c9e3818..797708d9 100644 --- a/flutter_custom_tabs_android/pigeons/messages.dart +++ b/flutter_custom_tabs_android/pigeons/messages.dart @@ -1,10 +1,8 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( - javaOut: - 'android/src/main/java/com/github/droibit/flutter/plugins/customtabs/Messages.java', - javaOptions: JavaOptions( - className: 'Messages', + kotlinOut: 'android/src/main/java/com/github/droibit/flutter/plugins/customtabs/Messages.kt', + kotlinOptions: KotlinOptions( package: 'com.github.droibit.flutter.plugins.customtabs', ), dartOut: 'lib/src/messages/messages.g.dart',