diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart index 9f3436fe..bd039f55 100644 --- a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart @@ -45,7 +45,12 @@ class NativeIntentGatekeeperReplicator Future? _pendingAllowSync; Future _push( - ({bool enabled, Map policies}) config, + ({ + bool enabled, + Map policies, + bool customTabsEnabled, + }) + config, ) async { final blocked = config.policies.entries .where((entry) => entry.value == IntentSourcePolicy.block) @@ -54,6 +59,7 @@ class NativeIntentGatekeeperReplicator try { await _api.setConfig(config.enabled, blocked); + await _api.setCustomTabsEnabled(config.customTabsEnabled); } catch (error, stackTrace) { logger.e( 'Failed to replicate intent gatekeeper config to native', @@ -116,6 +122,7 @@ class NativeIntentGatekeeperReplicator (settings) => EquatableValue(( enabled: settings.blockExternalAppsEnabled, policies: settings.externalAppIntentPolicies, + customTabsEnabled: settings.customTabsEnabled, )), ), fireImmediately: true, @@ -125,6 +132,7 @@ class NativeIntentGatekeeperReplicator if (previous != null && previous.enabled == next.enabled && + previous.customTabsEnabled == next.customTabsEnabled && const DeepCollectionEquality.unordered().equals( previous.policies, next.policies, @@ -139,6 +147,7 @@ class NativeIntentGatekeeperReplicator await _push(( enabled: settings.blockExternalAppsEnabled, policies: settings.externalAppIntentPolicies, + customTabsEnabled: settings.customTabsEnabled, )); }()); }, diff --git a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart index 6b4b2c89..1178d24f 100644 --- a/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart +++ b/apps/weblibre/lib/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.g.dart @@ -65,7 +65,7 @@ final class NativeIntentGatekeeperReplicatorProvider } String _$nativeIntentGatekeeperReplicatorHash() => - r'bb2e2a252143879e558d513f9c48f3e66513baa9'; + r'1425d7d540d872d82a2d97e0a9fff6a4f6d9ae7b'; /// Mirrors the Flutter-side block list to the native side so the /// `IntentReceiverActivity` can reject intents without launching Flutter. diff --git a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart index 9f11d866..368fceb9 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/browsing_settings.dart @@ -144,6 +144,20 @@ const List browsingSettingsSections = [ keywords: ['intents'], child: _ExternalLinkHandlingSection(), ), + SettingsEntryDefinition( + title: 'Custom Tabs', + subtitle: + 'Let other apps open links in a lightweight in-app tab, instead ' + 'of the main browser', + keywords: [ + 'custom tabs', + 'in-app browser', + 'chrome custom tabs', + 'external app', + 'share', + ], + child: _CustomTabsTile(), + ), SettingsEntryDefinition( title: 'URL Cleaner', subtitle: 'Tracking removal rules and catalog updates', @@ -821,6 +835,35 @@ class _PullToRefreshTile extends HookConsumerWidget { } } +class _CustomTabsTile extends HookConsumerWidget { + const _CustomTabsTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final customTabsEnabled = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.customTabsEnabled), + ); + + return SwitchListTile.adaptive( + title: const Text('Custom Tabs'), + subtitle: const Text( + 'Let other apps open links in a lightweight in-app tab. When off, ' + 'these links and shared URLs open as normal tabs in the main browser.', + ), + secondary: const Icon(Icons.web_asset), + value: customTabsEnabled, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.customTabsEnabled(value), + ); + }, + ); + } +} + class _DoubleBackCloseTabTile extends HookConsumerWidget { const _DoubleBackCloseTabTile(); diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index 90e21e5b..f9edd4a0 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -196,6 +196,12 @@ class GeneralSettings with FastEquatable { final bool blockExternalAppsEnabled; final Map externalAppIntentPolicies; + /// Whether external Custom Tab intents (and URLs shared into WebLibre) open + /// in a lightweight custom-tab activity. When false, they open as normal + /// tabs in the main browser instead. Read natively by `IntentReceiverActivity` + /// via the intent gatekeeper prefs bridge. Defaults to true. + final bool customTabsEnabled; + /// Whether the local search index (`history` table populated via tab→ /// history triggers) is active. When false, the SQL trigger guard returns /// without writing; existing rows stay until the user clears them. @@ -289,6 +295,7 @@ class GeneralSettings with FastEquatable { required this.allowNonManifestPwaInstall, required this.blockExternalAppsEnabled, required this.externalAppIntentPolicies, + required this.customTabsEnabled, required this.enableLocalSearchIndex, required this.indexPrivateTabs, required this.acceptSuggestionOnSubmit, @@ -356,6 +363,7 @@ class GeneralSettings with FastEquatable { bool? allowNonManifestPwaInstall, bool? blockExternalAppsEnabled, Map? externalAppIntentPolicies, + bool? customTabsEnabled, bool? enableLocalSearchIndex, bool? indexPrivateTabs, bool? acceptSuggestionOnSubmit, @@ -433,6 +441,7 @@ class GeneralSettings with FastEquatable { allowNonManifestPwaInstall = allowNonManifestPwaInstall ?? false, blockExternalAppsEnabled = blockExternalAppsEnabled ?? false, externalAppIntentPolicies = externalAppIntentPolicies ?? const {}, + customTabsEnabled = customTabsEnabled ?? true, enableLocalSearchIndex = enableLocalSearchIndex ?? true, indexPrivateTabs = indexPrivateTabs ?? false, acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? false, @@ -574,6 +583,7 @@ class GeneralSettings with FastEquatable { allowNonManifestPwaInstall, blockExternalAppsEnabled, externalAppIntentPolicies, + customTabsEnabled, enableLocalSearchIndex, indexPrivateTabs, acceptSuggestionOnSubmit, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart index c33b739d..95517dde 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.g.dart @@ -141,6 +141,8 @@ abstract class _$GeneralSettingsCWProxy { Map externalAppIntentPolicies, ); + GeneralSettings customTabsEnabled(bool customTabsEnabled); + GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex); GeneralSettings indexPrivateTabs(bool indexPrivateTabs); @@ -220,6 +222,7 @@ abstract class _$GeneralSettingsCWProxy { bool allowNonManifestPwaInstall, bool blockExternalAppsEnabled, Map externalAppIntentPolicies, + bool customTabsEnabled, bool enableLocalSearchIndex, bool indexPrivateTabs, bool acceptSuggestionOnSubmit, @@ -483,6 +486,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Map externalAppIntentPolicies, ) => call(externalAppIntentPolicies: externalAppIntentPolicies); + @override + GeneralSettings customTabsEnabled(bool customTabsEnabled) => + call(customTabsEnabled: customTabsEnabled); + @override GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) => call(enableLocalSearchIndex: enableLocalSearchIndex); @@ -578,6 +585,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? allowNonManifestPwaInstall = const $CopyWithPlaceholder(), Object? blockExternalAppsEnabled = const $CopyWithPlaceholder(), Object? externalAppIntentPolicies = const $CopyWithPlaceholder(), + Object? customTabsEnabled = const $CopyWithPlaceholder(), Object? enableLocalSearchIndex = const $CopyWithPlaceholder(), Object? indexPrivateTabs = const $CopyWithPlaceholder(), Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(), @@ -924,6 +932,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.externalAppIntentPolicies // ignore: cast_nullable_to_non_nullable : externalAppIntentPolicies as Map, + customTabsEnabled: + customTabsEnabled == const $CopyWithPlaceholder() || + customTabsEnabled == null + ? _value.customTabsEnabled + // ignore: cast_nullable_to_non_nullable + : customTabsEnabled as bool, enableLocalSearchIndex: enableLocalSearchIndex == const $CopyWithPlaceholder() || enableLocalSearchIndex == null @@ -1095,6 +1109,7 @@ GeneralSettings _$GeneralSettingsFromJson( (json['externalAppIntentPolicies'] as Map?)?.map( (k, e) => MapEntry(k, $enumDecode(_$IntentSourcePolicyEnumMap, e)), ), + customTabsEnabled: json['customTabsEnabled'] as bool?, enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?, indexPrivateTabs: json['indexPrivateTabs'] as bool?, acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?, @@ -1180,6 +1195,7 @@ Map _$GeneralSettingsToJson( 'externalAppIntentPolicies': instance.externalAppIntentPolicies.map( (k, e) => MapEntry(k, _$IntentSourcePolicyEnumMap[e]!), ), + 'customTabsEnabled': instance.customTabsEnabled, 'enableLocalSearchIndex': instance.enableLocalSearchIndex, 'indexPrivateTabs': instance.indexPrivateTabs, 'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit, diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart index e6424bf7..13f14476 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.dart @@ -268,6 +268,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { 'externalAppIntentPolicies': settings['externalAppIntentPolicies'] ?.readAs(DriftSqlType.string, db.typeMapping) .mapNotNull(jsonDecode), + 'customTabsEnabled': settings['customTabsEnabled']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), 'enableLocalSearchIndex': settings['enableLocalSearchIndex']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart index aef4b337..d7a3f00f 100644 --- a/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart +++ b/apps/weblibre/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'9ee41d8283340300ec295d318ade92c15b19426d'; + r'4e72c8ebed8b08ced417ca24d6e4a840f2abf1be'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt index 418dcfc0..68371238 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/IntentReceiverActivity.kt @@ -153,12 +153,14 @@ class IntentReceiverActivity : Activity() { // Flutter-side to handle (search, PDF display, etc.). if (intent.action == Intent.ACTION_SEND) { val shareUrl = extractShareUrl(intent) - if (shareUrl != null) { + if (shareUrl != null && + IntentGatekeeperPreferences.isCustomTabsEnabled(applicationContext) + ) { Log.d(TAG, "SHARE intent with URL, routing to custom tab: $shareUrl") handleShareUrlAsCustomTab(intent, shareUrl, privateBrowsingMode) return } - Log.d(TAG, "SHARE intent without URL, routing to MainActivity") + Log.d(TAG, "SHARE intent without URL (or custom tabs disabled), routing to MainActivity") handleRegularIntent(intent) return } @@ -193,19 +195,30 @@ class IntentReceiverActivity : Activity() { return } - val processors = listOf( - "CustomTab" to CustomTabIntentProcessor( - components.useCases.customTabsUseCases.add, - resources, - isPrivate = privateBrowsingMode, - ), - "PWA" to WebAppIntentProcessor( - components.core.store, - components.useCases.customTabsUseCases.addWebApp, - components.useCases.sessionUseCases.loadUrl, - components.core.webAppManifestStorage, - ), - ) + // When the user disables the Custom Tabs feature, external Custom Tab + // intents are not handled here; they fall through to the main browser + // via handleRegularIntent(). PWA processing is unaffected. + val customTabsEnabled = IntentGatekeeperPreferences.isCustomTabsEnabled(applicationContext) + + val processors = buildList { + if (customTabsEnabled) { + add( + "CustomTab" to CustomTabIntentProcessor( + components.useCases.customTabsUseCases.add, + resources, + isPrivate = privateBrowsingMode, + ), + ) + } + add( + "PWA" to WebAppIntentProcessor( + components.core.store, + components.useCases.customTabsUseCases.addWebApp, + components.useCases.sessionUseCases.loadUrl, + components.core.webAppManifestStorage, + ), + ) + } for ((name, processor) in processors) { Log.d(TAG, "Trying $name processor...") diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt index b10bd266..05ab2d8d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/gatekeeper/IntentGatekeeperPreferences.kt @@ -21,6 +21,7 @@ import java.util.UUID object IntentGatekeeperPreferences { const val PREFS_NAME = "weblibre_intent_gatekeeper" const val KEY_ENABLED = "enabled" + const val KEY_CUSTOM_TABS_ENABLED = "custom_tabs_enabled" const val KEY_BLOCKED_PACKAGES = "blocked_packages" const val KEY_PENDING_ALWAYS_ALLOW = "pending_always_allow" private const val KEY_NOTIFICATION_APPROVAL_TOKENS = "notification_approval_tokens" @@ -32,6 +33,15 @@ object IntentGatekeeperPreferences { fun isEnabled(context: Context): Boolean = get(context).getBoolean(KEY_ENABLED, false) + /** + * Whether external Custom Tab (and share-with-URL) intents should be + * handled as lightweight custom-tab activities. When false, + * [IntentReceiverActivity] routes them to the main browser instead. + * Defaults to true so the feature is active until the user opts out. + */ + fun isCustomTabsEnabled(context: Context): Boolean = + get(context).getBoolean(KEY_CUSTOM_TABS_ENABLED, true) + fun isBlocked(context: Context, packageName: String): Boolean { val prefs = get(context) if (!prefs.getBoolean(KEY_ENABLED, false)) return false diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt index 61c068ec..679268f6 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/IntentGatekeeperHostApiImpl.kt @@ -23,6 +23,7 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep companion object { private const val PREFS_NAME = "weblibre_intent_gatekeeper" private const val KEY_ENABLED = "enabled" + private const val KEY_CUSTOM_TABS_ENABLED = "custom_tabs_enabled" private const val KEY_BLOCKED_PACKAGES = "blocked_packages" private const val KEY_PENDING_ALWAYS_ALLOW = "pending_always_allow" } @@ -35,6 +36,13 @@ class IntentGatekeeperHostApiImpl(private val context: Context) : IntentGatekeep .apply() } + override fun setCustomTabsEnabled(enabled: Boolean) { + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit() + .putBoolean(KEY_CUSTOM_TABS_ENABLED, enabled) + .apply() + } + override fun resolvePackageLabel(packageName: String): String? { return try { val pm = context.applicationContext.packageManager diff --git a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt index 71e35342..c48d2b3a 100644 --- a/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt +++ b/packages/simple_intent_receiver/android/src/main/kotlin/eu/weblibre/simple_intent_receiver/pigeons/Intent.g.kt @@ -340,6 +340,13 @@ interface IntentGatekeeperHostApi { * [IntentReceiverActivity] can reject intents without launching Flutter. */ fun setConfig(enabled: Boolean, blockedPackages: List) + /** + * Replicates whether the Custom Tabs feature is enabled to the native side. + * When disabled, [IntentReceiverActivity] routes external Custom Tab and + * share-with-URL intents to the main browser instead of launching the + * stripped-down custom-tab activity. Defaults to enabled on the native side. + */ + fun setCustomTabsEnabled(enabled: Boolean) /** * Resolves a package name to its user-visible application label via * [PackageManager]. Returns `null` if the package is not installed or the @@ -388,6 +395,24 @@ interface IntentGatekeeperHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enabledArg = args[0] as Boolean + val wrapped: List = try { + api.setCustomTabsEnabled(enabledArg) + listOf(null) + } catch (exception: Throwable) { + IntentPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart index 909e9e7e..70a29fe6 100644 --- a/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart +++ b/packages/simple_intent_receiver/lib/src/pigeons/intent.g.dart @@ -301,6 +301,28 @@ class IntentGatekeeperHostApi { ; } + /// Replicates whether the Custom Tabs feature is enabled to the native side. + /// When disabled, [IntentReceiverActivity] routes external Custom Tab and + /// share-with-URL intents to the main browser instead of launching the + /// stripped-down custom-tab activity. Defaults to enabled on the native side. + Future setCustomTabsEnabled(bool enabled) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([enabled]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + /// Resolves a package name to its user-visible application label via /// [PackageManager]. Returns `null` if the package is not installed or the /// label cannot be resolved. diff --git a/packages/simple_intent_receiver/pigeons/intent.dart b/packages/simple_intent_receiver/pigeons/intent.dart index 62d55121..51ba2622 100644 --- a/packages/simple_intent_receiver/pigeons/intent.dart +++ b/packages/simple_intent_receiver/pigeons/intent.dart @@ -69,6 +69,12 @@ abstract class IntentGatekeeperHostApi { /// [IntentReceiverActivity] can reject intents without launching Flutter. void setConfig(bool enabled, List blockedPackages); + /// Replicates whether the Custom Tabs feature is enabled to the native side. + /// When disabled, [IntentReceiverActivity] routes external Custom Tab and + /// share-with-URL intents to the main browser instead of launching the + /// stripped-down custom-tab activity. Defaults to enabled on the native side. + void setCustomTabsEnabled(bool enabled); + /// Resolves a package name to its user-visible application label via /// [PackageManager]. Returns `null` if the package is not installed or the /// label cannot be resolved.