From a1e599f0ab1f6f1ec0aba1bddcf149aecd954e7b Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 25 Jan 2026 17:09:48 +0100 Subject: [PATCH] implement open in app feature --- .../browser_modules/bottom_app_bar.dart | 2 +- .../widgets/menu_item_buttons.dart | 31 +++++-- .../presentation/widgets/tab_menu.dart | 2 +- .../candidates/launch_external.dart | 16 ++-- .../presentation/context_menu_dialog.dart | 1 + .../api/GeckoAppLinksApiImpl.kt | 68 +++++++++++++++ .../api/GeckoBrowserApiImpl.kt | 2 + .../pigeons/Gecko.g.kt | 82 ++++++++++++++++++ .../lib/flutter_mozilla_components.dart | 1 + .../src/domain/services/gecko_app_links.dart | 40 +++++++++ .../lib/src/pigeons/gecko.g.dart | 86 +++++++++++++++++++ .../pigeons/gecko.dart | 31 +++++++ 12 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt create mode 100644 packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 2ea885ce..148c9d66 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -600,7 +600,7 @@ class ShareMenuButton extends HookConsumerWidget { controller: menuController, menuChildren: [ CopyAddressMenuItemButton(selectedTabId: selectedTabId), - LaunchExternalMenuItemButton(selectedTabId: selectedTabId), + OpenInAppMenuItemButton(selectedTabId: selectedTabId), ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), ShareMenuItemButton(selectedTabId: selectedTabId), ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 57489057..ed6ac2f9 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -22,6 +22,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:share_plus/share_plus.dart'; @@ -31,7 +32,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/dialog import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; -import 'package:weblibre/utils/ui_helper.dart' as ui_helper; +import 'package:weblibre/presentation/hooks/cached_future.dart'; class ShareMenuItemButton extends HookConsumerWidget { const ShareMenuItemButton({super.key, required this.selectedTabId}); @@ -225,23 +226,37 @@ class ShareScreenshotMenuItemButton extends HookConsumerWidget { } } -class LaunchExternalMenuItemButton extends HookConsumerWidget { - const LaunchExternalMenuItemButton({super.key, required this.selectedTabId}); +class OpenInAppMenuItemButton extends HookConsumerWidget { + const OpenInAppMenuItemButton({super.key, required this.selectedTabId}); + + static final _service = GeckoAppLinksService(); final String? selectedTabId; @override Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(tabStateProvider(selectedTabId)); + final url = tabState?.url; + final hasExternalApp = useCachedFuture( + // ignore: discarded_futures useFuture + () => url != null ? _service.hasExternalApp(url) : Future.value(false), + [url], + ); + + if (hasExternalApp.data != true) { + return const SizedBox.shrink(); + } + return MenuItemButton( - leadingIcon: const Icon(Icons.open_in_browser), + leadingIcon: const Icon(Icons.open_in_new), closeOnActivate: false, - child: const Text('Launch External'), + child: const Text('Open in App'), onPressed: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; + if (url == null) return; - await ui_helper.launchUrlFeedback(context, tabState.url); + final success = await _service.openAppLink(url); - if (context.mounted) { + if (success && context.mounted) { MenuController.maybeOf(context)?.close(); } }, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index aba8bf7e..2c2a22b3 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -388,7 +388,7 @@ class TabMenu extends HookConsumerWidget { SubmenuButton( menuChildren: [ CopyAddressMenuItemButton(selectedTabId: selectedTabId), - LaunchExternalMenuItemButton(selectedTabId: selectedTabId), + OpenInAppMenuItemButton(selectedTabId: selectedTabId), ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), ShareMenuItemButton(selectedTabId: selectedTabId), ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart index a99de4c2..7947a5fc 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart @@ -22,30 +22,32 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; -import 'package:weblibre/utils/ui_helper.dart'; class LaunchExternal extends HookConsumerWidget { final HitResult hitResult; const LaunchExternal({super.key, required this.hitResult}); + static final _service = GeckoAppLinksService(); + static Future isSupported(HitResult hitResult) async { - return hitResult.tryGetLink().mapNotNull((url) => canLaunchUrl(url)) ?? + return hitResult.tryGetLink().mapNotNull( + (url) => _service.hasExternalApp(url), + ) ?? false; } @override Widget build(BuildContext context, WidgetRef ref) { return ListTile( - leading: const Icon(Icons.open_in_browser), - title: const Text('Launch External'), + leading: const Icon(Icons.open_in_new), + title: const Text('Open in App'), onTap: () async { await hitResult.tryGetLink().mapNotNull((url) async { - await launchUrlFeedback(context, url); + final success = await _service.openAppLink(url); - if (context.mounted) { + if (success && context.mounted) { context.pop(); } }); diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart index 8cd048d1..2e350d18 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart @@ -74,6 +74,7 @@ class ContextMenuDialog extends HookConsumerWidget { final isSupported = useCachedFuture( // ignore: discarded_futures useFuture () => LaunchExternal.isSupported(hitResult), + [hitResult], ); if (isSupported.data == false) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt new file mode 100644 index 00000000..08d92af9 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAppLinksApiImpl.kt @@ -0,0 +1,68 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.api + +import android.content.Context +import android.content.Intent +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Implementation of GeckoAppLinksApi that detects and launches external applications + * that can handle URLs. + * + * This uses Mozilla Android Components' AppLinksUseCases to properly detect if a native + * app is available to handle a URL, matching the behavior in Firefox/Fenix. + */ +class GeckoAppLinksApiImpl( + private val context: Context +) : GeckoAppLinksApi { + companion object { + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun hasExternalApp(url: String, callback: (Result) -> Unit) { + coroutineScope.launch { + try { + val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) + callback(Result.success(redirect.hasExternalApp())) + } catch (e: Exception) { + callback(Result.success(false)) + } + } + } + + override fun openAppLink(url: String, callback: (Result) -> Unit) { + coroutineScope.launch { + try { + val redirect = components.useCases.appLinksUseCases.appLinkRedirect(url) + + if (!redirect.hasExternalApp()) { + callback(Result.success(false)) + return@launch + } + + // Set FLAG_ACTIVITY_NEW_TASK to launch in new task + // This prevents issues with app task stacks + redirect.appIntent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + components.useCases.appLinksUseCases.openAppLink.invoke(redirect.appIntent) + callback(Result.success(true)) + } catch (e: Exception) { + callback(Result.success(false)) + } + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 24ee8fe5..56bd7bae 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -21,6 +21,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAppLinksApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBookmarksApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi @@ -272,6 +273,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoSitePermissionsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSitePermissionsApiImpl()) GeckoPublicSuffixListApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPublicSuffixListApiImpl(profileApplicationContext)) GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl()) + GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext)) // Viewport API for dynamic toolbar and keyboard handling val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index ca129587..cb5dd270 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -7112,3 +7112,85 @@ interface GeckoTrackingProtectionApi { } } } +/** + * API for detecting and launching external applications that can handle URLs. + * + * This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter + * code to check if native apps can handle URLs and launch them directly. + * + * Generated interface from Pigeon that represents a handler of messages from Flutter. + */ +interface GeckoAppLinksApi { + /** + * Checks if an external application is available to handle the given URL. + * + * This method uses mozilla-components AppLinksUseCases to determine if + * a native app can handle the URL (e.g., YouTube app for youtube.com links). + * + * Returns true if an external app is available, false otherwise. + */ + fun hasExternalApp(url: String, callback: (Result) -> Unit) + /** + * Opens the URL in an external application if available. + * + * This method will: + * 1. Check if an external app can handle the URL + * 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK + * 3. Return true if successfully launched, false otherwise + * + * Returns true if URL was opened in external app, false if no app available. + */ + fun openAppLink(url: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoAppLinksApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoAppLinksApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoAppLinksApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.hasExternalApp$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.hasExternalApp(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.openAppLink$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + api.openAppLink(urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index cf3dfe80..b2e0821b 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -8,6 +8,7 @@ export 'src/data/models/load_url_flags.dart'; export 'src/data/models/source.dart'; export 'src/domain/entities/default_selection_actions.dart'; export 'src/domain/services/gecko_addon.dart'; +export 'src/domain/services/gecko_app_links.dart'; export 'src/domain/services/gecko_bookmarks.dart'; export 'src/domain/services/gecko_browser.dart'; export 'src/domain/services/gecko_browser_extension.dart'; diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart new file mode 100644 index 00000000..d5c17cae --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_app_links.dart @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; + +final _api = GeckoAppLinksApi(); + +/// Service for detecting and launching external applications that can handle URLs. +/// +/// This service wraps Mozilla Android Components' AppLinksUseCases to allow +/// checking if native apps can handle URLs and launching them directly. +/// This matches the behavior in Firefox/Fenix for "Open in App" functionality. +class GeckoAppLinksService { + /// Checks if an external application is available to handle the given URL. + /// + /// This method uses mozilla-components AppLinksUseCases to determine if + /// a native app can handle the URL (e.g., YouTube app for youtube.com links). + /// + /// @param url The URL to check. + /// @return true if an external app is available, false otherwise. + Future hasExternalApp(Uri url) { + return _api.hasExternalApp(url.toString()); + } + + /// Opens the URL in an external application if available. + /// + /// This method will: + /// 1. Check if an external app can handle the URL + /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK + /// 3. Return true if successfully launched, false otherwise + /// + /// @param url The URL to open in external app. + /// @return true if URL was opened in external app, false if no app available. + Future openAppLink(Uri url) { + return _api.openAppLink(url.toString()); + } +} diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index fe387835..f5db95ff 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -8272,3 +8272,89 @@ class GeckoTrackingProtectionApi { } } } + +/// API for detecting and launching external applications that can handle URLs. +/// +/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter +/// code to check if native apps can handle URLs and launch them directly. +class GeckoAppLinksApi { + /// Constructor for [GeckoAppLinksApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + GeckoAppLinksApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Checks if an external application is available to handle the given URL. + /// + /// This method uses mozilla-components AppLinksUseCases to determine if + /// a native app can handle the URL (e.g., YouTube app for youtube.com links). + /// + /// Returns true if an external app is available, false otherwise. + Future hasExternalApp(String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.hasExternalApp$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + /// Opens the URL in an external application if available. + /// + /// This method will: + /// 1. Check if an external app can handle the URL + /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK + /// 3. Return true if successfully launched, false otherwise + /// + /// Returns true if URL was opened in external app, false if no app available. + Future openAppLink(String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAppLinksApi.openAppLink$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } +} diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index c87e2caa..1ae98adf 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1977,3 +1977,34 @@ abstract class GeckoTrackingProtectionApi { @async void removeAllExceptions(); } + +// ============================================================================= +// App Links API +// ============================================================================= + +/// API for detecting and launching external applications that can handle URLs. +/// +/// This API wraps Mozilla Android Components' AppLinksUseCases to allow Flutter +/// code to check if native apps can handle URLs and launch them directly. +@HostApi() +abstract class GeckoAppLinksApi { + /// Checks if an external application is available to handle the given URL. + /// + /// This method uses mozilla-components AppLinksUseCases to determine if + /// a native app can handle the URL (e.g., YouTube app for youtube.com links). + /// + /// Returns true if an external app is available, false otherwise. + @async + bool hasExternalApp(String url); + + /// Opens the URL in an external application if available. + /// + /// This method will: + /// 1. Check if an external app can handle the URL + /// 2. If available, launch the app directly with Intent.FLAG_ACTIVITY_NEW_TASK + /// 3. Return true if successfully launched, false otherwise + /// + /// Returns true if URL was opened in external app, false if no app available. + @async + bool openAppLink(String url); +}