import 'dart:async'; import 'dart:collection'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../types/android_resource.dart'; import '../types/custom_tabs_navigation_event_type.dart'; import '../types/custom_tabs_relation_type.dart'; import '../types/prewarming_token.dart'; import '../types/ui_image.dart'; import '../util.dart'; import '../debug_logging_settings.dart'; import '../web_uri.dart'; import 'chrome_safari_browser_settings.dart'; class ChromeSafariBrowserAlreadyOpenedException implements Exception { final dynamic message; ChromeSafariBrowserAlreadyOpenedException([this.message]); String toString() { Object? message = this.message; if (message == null) return "ChromeSafariBrowserAlreadyOpenedException"; return "ChromeSafariBrowserAlreadyOpenedException: $message"; } } class ChromeSafariBrowserNotOpenedException implements Exception { final dynamic message; ChromeSafariBrowserNotOpenedException([this.message]); String toString() { Object? message = this.message; if (message == null) return "ChromeSafariBrowserNotOpenedException"; return "ChromeSafariBrowserNotOpenedException: $message"; } } ///This class uses native [Chrome Custom Tabs](https://developer.android.com/reference/android/support/customtabs/package-summary) on Android ///and [SFSafariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller) on iOS. /// ///**NOTE**: If you want to use the `ChromeSafariBrowser` class on Android 11+ you need to specify your app querying for ///`android.support.customtabs.action.CustomTabsService` in your `AndroidManifest.xml` ///(you can read more about it here: https://developers.google.com/web/android/custom-tabs/best-practices#applications_targeting_android_11_api_level_30_or_above). /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS class ChromeSafariBrowser { ///Debug settings. static DebugLoggingSettings debugLoggingSettings = DebugLoggingSettings(); ///View ID used internally. late final String id; ChromeSafariBrowserActionButton? _actionButton; Map _menuItems = new HashMap(); ChromeSafariBrowserSecondaryToolbar? _secondaryToolbar; bool _isOpened = false; late MethodChannel _channel; static const MethodChannel _sharedChannel = const MethodChannel('com.pichillilorenzo/flutter_chromesafaribrowser'); ChromeSafariBrowser() { id = IdGenerator.generate(); this._channel = MethodChannel('com.pichillilorenzo/flutter_chromesafaribrowser_$id'); this._channel.setMethodCallHandler((call) async { try { return await _handleMethod(call); } on Error catch (e) { print(e); print(e.stackTrace); } }); _isOpened = false; } _debugLog(String method, dynamic args) { debugLog( className: this.runtimeType.toString(), id: id, debugLoggingSettings: ChromeSafariBrowser.debugLoggingSettings, method: method, args: args); } Future _handleMethod(MethodCall call) async { _debugLog(call.method, call.arguments); switch (call.method) { case "onServiceConnected": onServiceConnected(); break; case "onOpened": onOpened(); break; case "onCompletedInitialLoad": final bool? didLoadSuccessfully = call.arguments["didLoadSuccessfully"]; onCompletedInitialLoad(didLoadSuccessfully); break; case "onInitialLoadDidRedirect": final String? url = call.arguments["url"]; final WebUri? uri = url != null ? WebUri(url) : null; onInitialLoadDidRedirect(uri); break; case "onNavigationEvent": final navigationEvent = CustomTabsNavigationEventType.fromNativeValue( call.arguments["navigationEvent"]); onNavigationEvent(navigationEvent); break; case "onRelationshipValidationResult": final relation = CustomTabsRelationType.fromNativeValue(call.arguments["relation"]); final requestedOrigin = call.arguments["requestedOrigin"] != null ? WebUri(call.arguments["requestedOrigin"]) : null; final bool result = call.arguments["result"]; onRelationshipValidationResult(relation, requestedOrigin, result); break; case "onWillOpenInBrowser": onWillOpenInBrowser(); break; case "onClosed": onClosed(); this._isOpened = false; break; case "onItemActionPerform": String url = call.arguments["url"]; String title = call.arguments["title"]; int id = call.arguments["id"].toInt(); if (this._actionButton?.id == id) { if (this._actionButton?.action != null) { this._actionButton?.action!(url, title); } if (this._actionButton?.onClick != null) { this._actionButton?.onClick!(WebUri(url), title); } } else if (this._menuItems[id] != null) { if (this._menuItems[id]?.action != null) { this._menuItems[id]?.action!(url, title); } if (this._menuItems[id]?.onClick != null) { this._menuItems[id]?.onClick!(WebUri(url), title); } } break; case "onSecondaryItemActionPerform": final clickableIDs = this._secondaryToolbar?.clickableIDs; if (clickableIDs != null) { WebUri? url = call.arguments["url"] != null ? WebUri(call.arguments["url"]) : null; String name = call.arguments["name"]; for (final clickable in clickableIDs) { var clickableFullname = clickable.id.name; if (clickable.id.defType != null && !clickableFullname.contains("/")) { clickableFullname = "${clickable.id.defType}/$clickableFullname"; } if (clickable.id.defPackage != null && !clickableFullname.contains(":")) { clickableFullname = "${clickable.id.defPackage}:$clickableFullname"; } if (clickableFullname == name) { if (clickable.onClick != null) { clickable.onClick!(url); } break; } } } break; default: throw UnimplementedError("Unimplemented ${call.method} method"); } } ///Opens the [ChromeSafariBrowser] instance with an [url]. /// ///[url] - The [url] to load. On iOS, the [url] is required and must use the `http` or `https` scheme. /// ///[headers] (Supported only on Android) - [whitelisted](https://fetch.spec.whatwg.org/#cors-safelisted-request-header) cross-origin request headers. ///It is possible to attach non-whitelisted headers to cross-origin requests, when the server and client are related using a ///[digital asset link](https://developers.google.com/digital-asset-links/v1/getting-started). /// ///[otherLikelyURLs] - Other likely destinations, sorted in decreasing likelihood order. Supported only on Android. /// ///[referrer] - referrer header. Supported only on Android. /// ///[options] - Deprecated. Use `settings` instead. /// ///[settings] - Settings for the [ChromeSafariBrowser]. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS Future open( {WebUri? url, Map? headers, List? otherLikelyURLs, WebUri? referrer, @Deprecated('Use settings instead') // ignore: deprecated_member_use_from_same_package ChromeSafariBrowserClassOptions? options, ChromeSafariBrowserSettings? settings}) async { if (Util.isIOS) { assert(url != null, 'The specified URL must not be null on iOS.'); assert(['http', 'https'].contains(url!.scheme), 'The specified URL has an unsupported scheme. Only HTTP and HTTPS URLs are supported on iOS.'); } if (url != null) { assert(url.toString().isNotEmpty, 'The specified URL must not be empty.'); } this.throwIsAlreadyOpened(message: url != null ? 'Cannot open $url!' : ''); this._isOpened = true; List> menuItemList = []; _menuItems.forEach((key, value) { menuItemList.add(value.toMap()); }); var initialSettings = settings?.toMap() ?? options?.toMap() ?? ChromeSafariBrowserSettings().toMap(); Map args = {}; args.putIfAbsent('id', () => id); args.putIfAbsent('url', () => url?.toString()); args.putIfAbsent('headers', () => headers); args.putIfAbsent('otherLikelyURLs', () => otherLikelyURLs?.map((e) => e.toString()).toList()); args.putIfAbsent('referrer', () => referrer?.toString()); args.putIfAbsent('settings', () => initialSettings); args.putIfAbsent('actionButton', () => _actionButton?.toMap()); args.putIfAbsent('secondaryToolbar', () => _secondaryToolbar?.toMap()); args.putIfAbsent('menuItemList', () => menuItemList); await _sharedChannel.invokeMethod('open', args); } ///Tells the browser to launch with [url]. /// ///[url] - initial url. /// ///[headers] (Supported only on Android) - [whitelisted](https://fetch.spec.whatwg.org/#cors-safelisted-request-header) cross-origin request headers. ///It is possible to attach non-whitelisted headers to cross-origin requests, when the server and client are related using a ///[digital asset link](https://developers.google.com/digital-asset-links/v1/getting-started). /// ///[otherLikelyURLs] - Other likely destinations, sorted in decreasing likelihood order. /// ///[referrer] - referrer header. Supported only on Android. /// ///**Supported Platforms/Implementations**: ///- Android Future launchUrl({ required WebUri url, Map? headers, List? otherLikelyURLs, WebUri? referrer, }) async { Map args = {}; args.putIfAbsent('url', () => url.toString()); args.putIfAbsent('headers', () => headers); args.putIfAbsent('otherLikelyURLs', () => otherLikelyURLs?.map((e) => e.toString()).toList()); args.putIfAbsent('referrer', () => referrer?.toString()); await _channel.invokeMethod("launchUrl", args); } ///Tells the browser of a likely future navigation to a URL. ///The most likely URL has to be specified first. ///Optionally, a list of other likely URLs can be provided. ///They are treated as less likely than the first one, and have to be sorted in decreasing priority order. ///These additional URLs may be ignored. All previous calls to this method will be deprioritized. /// ///[url] - Most likely URL, may be null if otherLikelyBundles is provided. /// ///[otherLikelyURLs] - Other likely destinations, sorted in decreasing likelihood order. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsSession.mayLaunchUrl](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsSession#mayLaunchUrl(android.net.Uri,android.os.Bundle,java.util.List%3Candroid.os.Bundle%3E))) Future mayLaunchUrl( {WebUri? url, List? otherLikelyURLs}) async { Map args = {}; args.putIfAbsent('url', () => url?.toString()); args.putIfAbsent('otherLikelyURLs', () => otherLikelyURLs?.map((e) => e.toString()).toList()); return await _channel.invokeMethod("mayLaunchUrl", args); } ///Requests to validate a relationship between the application and an origin. /// ///See [here](https://developers.google.com/digital-asset-links/v1/getting-started) for documentation about Digital Asset Links. ///This methods requests the browser to verify a relation with the calling application, to grant the associated rights. /// ///If this method returns `true`, the validation result will be provided through [onRelationshipValidationResult]. ///Otherwise the request didn't succeed. /// ///[relation] – Relation to check, must be one of the [CustomTabsRelationType] constants. /// ///[origin] – Origin. /// ///[extras] – Reserved for future use. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsSession.validateRelationship](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsSession#validateRelationship(int,android.net.Uri,android.os.Bundle))) Future validateRelationship( {required CustomTabsRelationType relation, required WebUri origin}) async { Map args = {}; args.putIfAbsent('relation', () => relation.toNativeValue()); args.putIfAbsent('origin', () => origin.toString()); return await _channel.invokeMethod("validateRelationship", args); } ///Closes the [ChromeSafariBrowser] instance. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS Future close() async { Map args = {}; await _channel.invokeMethod("close", args); } ///Set a custom action button. /// ///**NOTE**: Not available in a Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsIntent.Builder.setActionButton](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsIntent.Builder#setActionButton(android.graphics.Bitmap,%20java.lang.String,%20android.app.PendingIntent,%20boolean))) void setActionButton(ChromeSafariBrowserActionButton actionButton) { this._actionButton = actionButton; } ///Updates the [ChromeSafariBrowserActionButton.icon] and [ChromeSafariBrowserActionButton.description]. /// ///**NOTE**: Not available in a Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsSession.setActionButton](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsSession#setActionButton(android.graphics.Bitmap,java.lang.String))) Future updateActionButton( {required Uint8List icon, required String description}) async { Map args = {}; args.putIfAbsent('icon', () => icon); args.putIfAbsent('description', () => description); await _channel.invokeMethod("updateActionButton", args); _actionButton?.icon = icon; _actionButton?.description = description; } ///Sets the remote views displayed in the secondary toolbar in a custom tab. /// ///**NOTE**: Not available in a Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsIntent.Builder.setSecondaryToolbarViews](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsIntent.Builder#setSecondaryToolbarViews(android.widget.RemoteViews,int[],android.app.PendingIntent))) void setSecondaryToolbar( ChromeSafariBrowserSecondaryToolbar secondaryToolbar) { this._secondaryToolbar = secondaryToolbar; } ///Sets or updates (if already present) the Remote Views of the secondary toolbar in an existing custom tab session. /// ///**NOTE**: Not available in a Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsSession.setSecondaryToolbarViews](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsSession#setSecondaryToolbarViews(android.widget.RemoteViews,int[],android.app.PendingIntent))) Future updateSecondaryToolbar( ChromeSafariBrowserSecondaryToolbar secondaryToolbar) async { Map args = {}; args.putIfAbsent('secondaryToolbar', () => secondaryToolbar.toMap()); await _channel.invokeMethod("updateSecondaryToolbar", args); this._secondaryToolbar = secondaryToolbar; } ///Adds a [ChromeSafariBrowserMenuItem] to the menu. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS void addMenuItem(ChromeSafariBrowserMenuItem menuItem) { this._menuItems[menuItem.id] = menuItem; } ///Adds a list of [ChromeSafariBrowserMenuItem] to the menu. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS void addMenuItems(List menuItems) { menuItems.forEach((menuItem) { this._menuItems[menuItem.id] = menuItem; }); } ///On Android, returns `true` if Chrome Custom Tabs is available. ///On iOS, returns `true` if SFSafariViewController is available. ///Otherwise returns `false`. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS static Future isAvailable() async { Map args = {}; return await _sharedChannel.invokeMethod("isAvailable", args); } ///The maximum number of allowed secondary toolbar items. /// ///**Supported Platforms/Implementations**: ///- Android static Future getMaxToolbarItems() async { Map args = {}; return await _sharedChannel.invokeMethod("getMaxToolbarItems", args); } ///Clear associated website data accrued from browsing activity within your app. ///This includes all local storage, cached resources, and cookies. /// ///**NOTE for iOS**: available on iOS 16.0+. /// ///**Supported Platforms/Implementations**: ///- iOS ([Official API - SFSafariViewController.DataStore.clearWebsiteData](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller/datastore/3981117-clearwebsitedata)) static Future clearWebsiteData() async { Map args = {}; await _sharedChannel.invokeMethod("clearWebsiteData", args); } ///Prewarms a connection to each URL. SFSafariViewController will automatically use a ///prewarmed connection if possible when loading its initial URL. /// ///Returns a token object that corresponds to the requested URLs. You must keep a strong ///reference to this token as long as you expect the prewarmed connections to remain open. If the same ///server is requested in multiple calls to this method, all of the corresponding tokens must be ///invalidated or released to end the prewarmed connection to that server. /// ///This method uses a best-effort approach to prewarming connections, but may delay ///or drop requests based on the volume of requests made by your app. Use this method when you expect ///to present the browser soon. Many HTTP servers time out connections after a few minutes. ///After a timeout, prewarming delivers less performance benefit. /// ///[URLs] - the URLs of servers that the browser should prewarm connections to. ///Only supports URLs with `http://` or `https://` schemes. /// ///**NOTE for iOS**: available on iOS 15.0+. /// ///**Supported Platforms/Implementations**: ///- iOS ([Official API - SFSafariViewController.prewarmConnections](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller/3752133-prewarmconnections)) static Future prewarmConnections(List URLs) async { Map args = {}; args.putIfAbsent('URLs', () => URLs.map((e) => e.toString()).toList()); Map? result = (await _sharedChannel.invokeMethod("prewarmConnections", args)) ?.cast(); return PrewarmingToken.fromMap(result); } ///Ends all prewarmed connections associated with the token, except for connections that are also kept alive by other tokens. /// ///**NOTE for iOS**: available on iOS 15.0+. /// ///**Supported Platforms/Implementations**: ///- iOS ([Official API - SFSafariViewController.prewarmConnections](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller/3752133-prewarmconnections)) static Future invalidatePrewarmingToken( PrewarmingToken prewarmingToken) async { Map args = {}; args.putIfAbsent('prewarmingToken', () => prewarmingToken.toMap()); await _sharedChannel.invokeMethod("invalidatePrewarmingToken", args); } ///Event fired when the when connecting from Android Custom Tabs Service. /// ///**Supported Platforms/Implementations**: ///- Android void onServiceConnected() {} ///Event fired when the [ChromeSafariBrowser] is opened. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS void onOpened() {} ///Event fired when the initial URL load is complete. /// ///[didLoadSuccessfully] - `true` if loading completed successfully; otherwise, `false`. Supported only on iOS. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS ([Official API - SFSafariViewControllerDelegate.safariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontrollerdelegate/1621215-safariviewcontroller)) void onCompletedInitialLoad(bool? didLoadSuccessfully) {} ///Event fired when the initial URL load is complete. /// ///**Supported Platforms/Implementations**: ///- iOS ([Official API - SFSafariViewControllerDelegate.safariViewController](https://developer.apple.com/documentation/safariservices/sfsafariviewcontrollerdelegate/2923545-safariviewcontroller)) void onInitialLoadDidRedirect(WebUri? url) {} ///Event fired when a navigation event happens. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsCallback.onNavigationEvent](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsCallback#onNavigationEvent(int,android.os.Bundle))) void onNavigationEvent(CustomTabsNavigationEventType? navigationEvent) {} ///Event fired when a relationship validation result is available. /// ///[relation] - Relation for which the result is available. Value previously passed to [validateRelationship]. /// ///[requestedOrigin] - Origin requested. Value previously passed to [validateRelationship]. /// ///[result] - Whether the relation was validated. /// ///**Supported Platforms/Implementations**: ///- Android ([Official API - CustomTabsCallback.onRelationshipValidationResult](https://developer.android.com/reference/androidx/browser/customtabs/CustomTabsCallback#onRelationshipValidationResult(int,android.net.Uri,boolean,android.os.Bundle))) void onRelationshipValidationResult( CustomTabsRelationType? relation, WebUri? requestedOrigin, bool result) {} ///Event fired when the user opens the current page in the default browser by tapping the toolbar button. /// ///**NOTE for iOS**: available on iOS 14.0+. /// ///**Supported Platforms/Implementations**: ///- iOS ([Official API - SFSafariViewControllerDelegate.safariViewControllerWillOpenInBrowser](https://developer.apple.com/documentation/safariservices/sfsafariviewcontrollerdelegate/3650426-safariviewcontrollerwillopeninbr)) void onWillOpenInBrowser() {} ///Event fired when the [ChromeSafariBrowser] is closed. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS void onClosed() {} ///Returns `true` if the [ChromeSafariBrowser] instance is opened, otherwise `false`. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS bool isOpened() { return this._isOpened; } void throwIsAlreadyOpened({String message = ''}) { if (this.isOpened()) { throw ChromeSafariBrowserAlreadyOpenedException([ 'Error: ${(message.isEmpty) ? '' : message + ' '}The browser is already opened.' ]); } } void throwIsNotOpened({String message = ''}) { if (!this.isOpened()) { throw ChromeSafariBrowserNotOpenedException([ 'Error: ${(message.isEmpty) ? '' : message + ' '}The browser is not opened.' ]); } } } ///Class that represents a custom action button for a [ChromeSafariBrowser] instance. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android class ChromeSafariBrowserActionButton { ///The action button id. It should be different from the [ChromeSafariBrowserMenuItem.id]. int id; ///The icon byte data. Uint8List icon; ///The description for the button. To be used for accessibility. String description; ///Whether the action button should be tinted. bool shouldTint; ///Use onClick instead. @Deprecated("Use onClick instead") void Function(String url, String title)? action; ///Callback function to be invoked when the action button is clicked void Function(WebUri? url, String title)? onClick; ChromeSafariBrowserActionButton( {required this.id, required this.icon, required this.description, @Deprecated("Use onClick instead") this.action, this.onClick, this.shouldTint = false}); Map toMap() { return { "id": id, "icon": icon, "description": description, "shouldTint": shouldTint }; } Map toJson() { return this.toMap(); } @override String toString() { return toMap().toString(); } } ///Class that represents a custom menu item for a [ChromeSafariBrowser] instance. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android ///- iOS class ChromeSafariBrowserMenuItem { ///The menu item id. It should be different from [ChromeSafariBrowserActionButton.id]. int id; ///The label of the menu item. String label; ///Item image. UIImage? image; ///Use onClick instead. @Deprecated("Use onClick instead") void Function(String url, String title)? action; ///Callback function to be invoked when the menu item is clicked void Function(WebUri? url, String title)? onClick; ChromeSafariBrowserMenuItem( {required this.id, required this.label, this.image, @Deprecated("Use onClick instead") this.action, this.onClick}); Map toMap() { return {"id": id, "label": label, "image": image?.toMap()}; } Map toJson() { return this.toMap(); } @override String toString() { return toMap().toString(); } } ///Class that represents the [RemoteViews](https://developer.android.com/reference/android/widget/RemoteViews.html) ///that will be shown on the secondary toolbar of a custom tab. /// ///This class describes a view hierarchy that can be displayed in another process. ///The hierarchy is inflated from an Android layout resource file. /// ///RemoteViews has limited to support to Android layouts. ///Check the [RemoteViews Official API](https://developer.android.com/reference/android/widget/RemoteViews.html) for more details. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android class ChromeSafariBrowserSecondaryToolbar { ///The android layout resource. AndroidResource layout; ///The IDs of clickable views. The `onClick` event of these views will be handled by custom tabs. List clickableIDs; ChromeSafariBrowserSecondaryToolbar( {required this.layout, this.clickableIDs = const []}); Map toMap() { return { "layout": layout.toMap(), "clickableIDs": clickableIDs.map((e) => e.toMap()).toList() }; } Map toJson() { return this.toMap(); } @override String toString() { return toMap().toString(); } } ///Class that represents a clickable ID item of the secondary toolbar for a [ChromeSafariBrowser] instance. /// ///**NOTE**: Not available in an Android Trusted Web Activity. /// ///**Supported Platforms/Implementations**: ///- Android class ChromeSafariBrowserSecondaryToolbarClickableID { ///The android id resource AndroidResource id; ///Callback function to be invoked when the item is clicked void Function(WebUri? url)? onClick; ChromeSafariBrowserSecondaryToolbarClickableID( {required this.id, this.onClick}); Map toMap() { return {"id": id.toMap()}; } Map toJson() { return this.toMap(); } @override String toString() { return toMap().toString(); } }