diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart index 864c7e54..ef3b5887 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart @@ -99,6 +99,23 @@ class EngineSettingsReplicationService }, ); + ref.listen( + fireImmediately: true, + generalSettingsWithDefaultsProvider.select( + (settings) => settings.useExternalDownloadManager, + ), + (previous, next) async { + await _service.setUseExternalDownloadManager(next); + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to useExternalDownloadManager', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listen( fireImmediately: true, engineSettingsRepositoryProvider, diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart index d194ab20..f8a5cc96 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart @@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider } String _$engineSettingsReplicationServiceHash() => - r'3e06f0c273090f9f9dfc42f2140d60ef9557f176'; + r'7a4db374b521e592847a85cb8d2aa184be788ee4'; abstract class _$EngineSettingsReplicationService extends $Notifier { void build(); diff --git a/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart b/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart index 2d49fb6f..dbe1a18f 100644 --- a/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart +++ b/app/lib/features/settings/presentation/screens/tabs_behavior_settings.dart @@ -49,6 +49,7 @@ class TabsBehaviorSettingsScreen extends StatelessWidget { _TabCreationSection(), _TabOrganizationSection(), _TabInteractionSection(), + _DownloadsSection(), ], ); }, @@ -404,3 +405,45 @@ class _AppLinksModeSection extends HookConsumerWidget { ); } } + +class _DownloadsSection extends StatelessWidget { + const _DownloadsSection(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + SettingSection(name: 'Downloads'), + _ExternalDownloadManagerTile(), + ], + ); + } +} + +class _ExternalDownloadManagerTile extends HookConsumerWidget { + const _ExternalDownloadManagerTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final useExternalDownloadManager = ref.watch( + generalSettingsWithDefaultsProvider.select( + (s) => s.useExternalDownloadManager, + ), + ); + + return SwitchListTile.adaptive( + title: const Text('Use external download manager'), + subtitle: const Text('Manage downloads with another app'), + secondary: const Icon(MdiIcons.download), + value: useExternalDownloadManager, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => + currentSettings.copyWith.useExternalDownloadManager(value), + ); + }, + ); + } +} diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index cca8fc25..616a1467 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -81,6 +81,7 @@ class GeneralSettings with FastEquatable { final TabBarPosition tabBarPosition; final QuickTabSwitcherMode quickTabSwitcherMode; final bool pullToRefreshEnabled; + final bool useExternalDownloadManager; final bool doubleBackCloseTab; final Duration unassignedTabsAutoCleanInterval; final int maxSearchHistoryEntries; @@ -107,6 +108,7 @@ class GeneralSettings with FastEquatable { required this.tabBarPosition, required this.quickTabSwitcherMode, required this.pullToRefreshEnabled, + required this.useExternalDownloadManager, required this.doubleBackCloseTab, required this.unassignedTabsAutoCleanInterval, required this.maxSearchHistoryEntries, @@ -134,6 +136,7 @@ class GeneralSettings with FastEquatable { TabBarPosition? tabBarPosition, QuickTabSwitcherMode? quickTabSwitcherMode, bool? pullToRefreshEnabled, + bool? useExternalDownloadManager, bool? doubleBackCloseTab, Duration? unassignedTabsAutoCleanInterval, int? maxSearchHistoryEntries, @@ -161,6 +164,7 @@ class GeneralSettings with FastEquatable { quickTabSwitcherMode = quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs, pullToRefreshEnabled = pullToRefreshEnabled ?? true, + useExternalDownloadManager = useExternalDownloadManager ?? false, doubleBackCloseTab = doubleBackCloseTab ?? true, unassignedTabsAutoCleanInterval = unassignedTabsAutoCleanInterval ?? Duration.zero, @@ -194,6 +198,7 @@ class GeneralSettings with FastEquatable { tabBarPosition, quickTabSwitcherMode, pullToRefreshEnabled, + useExternalDownloadManager, doubleBackCloseTab, unassignedTabsAutoCleanInterval, maxSearchHistoryEntries, diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 2b73af13..54da87be 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -59,6 +59,8 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled); + GeneralSettings useExternalDownloadManager(bool useExternalDownloadManager); + GeneralSettings doubleBackCloseTab(bool doubleBackCloseTab); GeneralSettings unassignedTabsAutoCleanInterval( @@ -96,6 +98,7 @@ abstract class _$GeneralSettingsCWProxy { TabBarPosition tabBarPosition, QuickTabSwitcherMode quickTabSwitcherMode, bool pullToRefreshEnabled, + bool useExternalDownloadManager, bool doubleBackCloseTab, Duration unassignedTabsAutoCleanInterval, int maxSearchHistoryEntries, @@ -197,6 +200,10 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled) => call(pullToRefreshEnabled: pullToRefreshEnabled); + @override + GeneralSettings useExternalDownloadManager(bool useExternalDownloadManager) => + call(useExternalDownloadManager: useExternalDownloadManager); + @override GeneralSettings doubleBackCloseTab(bool doubleBackCloseTab) => call(doubleBackCloseTab: doubleBackCloseTab); @@ -240,6 +247,7 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? tabBarPosition = const $CopyWithPlaceholder(), Object? quickTabSwitcherMode = const $CopyWithPlaceholder(), Object? pullToRefreshEnabled = const $CopyWithPlaceholder(), + Object? useExternalDownloadManager = const $CopyWithPlaceholder(), Object? doubleBackCloseTab = const $CopyWithPlaceholder(), Object? unassignedTabsAutoCleanInterval = const $CopyWithPlaceholder(), Object? maxSearchHistoryEntries = const $CopyWithPlaceholder(), @@ -367,6 +375,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.pullToRefreshEnabled // ignore: cast_nullable_to_non_nullable : pullToRefreshEnabled as bool, + useExternalDownloadManager: + useExternalDownloadManager == const $CopyWithPlaceholder() || + useExternalDownloadManager == null + ? _value.useExternalDownloadManager + // ignore: cast_nullable_to_non_nullable + : useExternalDownloadManager as bool, doubleBackCloseTab: doubleBackCloseTab == const $CopyWithPlaceholder() || doubleBackCloseTab == null @@ -450,6 +464,7 @@ GeneralSettings _$GeneralSettingsFromJson( json['quickTabSwitcherMode'], ), pullToRefreshEnabled: json['pullToRefreshEnabled'] as bool?, + useExternalDownloadManager: json['useExternalDownloadManager'] as bool?, doubleBackCloseTab: json['doubleBackCloseTab'] as bool?, unassignedTabsAutoCleanInterval: json['unassignedTabsAutoCleanInterval'] == null @@ -493,6 +508,7 @@ Map _$GeneralSettingsToJson( 'quickTabSwitcherMode': _$QuickTabSwitcherModeEnumMap[instance.quickTabSwitcherMode]!, 'pullToRefreshEnabled': instance.pullToRefreshEnabled, + 'useExternalDownloadManager': instance.useExternalDownloadManager, 'doubleBackCloseTab': instance.doubleBackCloseTab, 'unassignedTabsAutoCleanInterval': instance.unassignedTabsAutoCleanInterval.inMicroseconds, diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index e9ad04d0..50c56b50 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -126,6 +126,8 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'useExternalDownloadManager': settings['useExternalDownloadManager'] + ?.readAs(DriftSqlType.bool, db.typeMapping), 'doubleBackCloseTab': settings['doubleBackCloseTab']?.readAs( DriftSqlType.bool, db.typeMapping, diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index aaeab871..54c9a506 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'0d4b01ff15942b513a7cdceee7bf43721ae1c074'; + r'7cea68308e2e7847e9bb030e9c9c04860c760edd'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/docs/external_download_manager_implementation_plan.md b/docs/external_download_manager_implementation_plan.md new file mode 100644 index 00000000..08487fe2 --- /dev/null +++ b/docs/external_download_manager_implementation_plan.md @@ -0,0 +1,379 @@ +# External Download Manager Feature - Implementation Plan + +## Overview +Implement support for external download managers (ADM, 1DM, AB DM) as requested in GitHub issue #146. +The feature uses a database-stored setting (similar to AppLinks) rather than SharedPreferences, +and configures Mozilla's DownloadsFeature to forward downloads to third-party apps. + +## Reference Implementation (Fenix) +- **Preference Key**: `pref_key_external_download_manager` +- **Usage in Fenix**: + - `fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt:759` - DownloadsFeature configuration + - `fenix/app/src/main/java/org/mozilla/fenix/addons/AddonPopupBaseFragment.kt:93` - DownloadsFeature in addons + - `fenix/app/src/main/res/xml/downloads_settings_preferences.xml:8` - Settings UI XML + - `fenix/app/src/main/res/values/preference_keys.xml:503` - Preference key definition + +## WebLibre Pattern Reference (AppLinks) +The implementation should follow the existing AppLinks pattern as a reference: +- **Model**: `app/lib/features/user/data/models/general_settings.dart` - AppLinksMode not here (it's in native), but this shows the pattern +- **Repository**: `app/lib/features/user/domain/repositories/general_settings.dart:37-195` - GeneralSettingsRepository pattern +- **Native Storage**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt:325-352` - AppLinks storage in SharedPreferences (our implementation will use global state set by Flutter) +- **Native Usage**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt:60-74` - shouldOpenLinksInApp/shouldPromptOpenLinksInApp pattern +- **Pigeon API**: `packages/flutter_mozilla_components/pigeons/gecko.dart:643-653` - AppLinksMode enum +- **Provider**: `app/lib/features/geckoview/features/browser/domain/providers.dart:382-395` - AppLinksModeNotifier +- **Service**: `packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart:142-150` - setAppLinksMode/getAppLinksMode + +## Architecture + +### Data Flow +1. **Database Storage**: Setting stored in `user.db` -> `setting` table with partition_key='general' +2. **Flutter Layer**: GeneralSettings model manages the setting state +3. **Pigeon Bridge**: `setUseExternalDownloadManager(bool)`/`getUseExternalDownloadManager()` sync to native +4. **Native Layer**: GlobalComponents stores value as `var useExternalDownloadManager: Boolean` +5. **DownloadsFeature**: Uses `shouldForwardToThirdParties = { GlobalComponents.useExternalDownloadManager }` + +### Key Components +- **Setting Type**: Boolean (default: false) +- **Storage Location**: user.db, setting table, partition_key='general', key='useExternalDownloadManager' +- **Native Integration**: Mozilla Android Components DownloadsFeature's shouldForwardToThirdParties callback + +## Implementation Steps + +### Phase 1: Data Model & Database Layer + +#### Step 1.1 - Update GeneralSettings Model +**File**: `app/lib/features/user/data/models/general_settings.dart` + +**Changes Required**: +- Add new field declaration: `final bool useExternalDownloadManager;` +- Add field to constructor parameters +- Add field to `withDefaults()` constructor (default value: `false`) +- Add field to `fromJson()`/`toJson()` methods via @JsonSerializable annotation +- Add field to `hashParameters` getter for FastEquatable + +**Reference**: Look at `pullToRefreshEnabled` field (line 83) as a template + +#### Step 1.2 - Update GeneralSettingsRepository +**File**: `app/lib/features/user/domain/repositories/general_settings.dart` + +**Changes Required**: +- Add deserialization logic in `_deserializeSettings()` method (around line 142) +- Pattern: + ```dart + 'useExternalDownloadManager': settings['useExternalDownloadManager']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + ``` +- This follows the same pattern as existing fields like `pullToRefreshEnabled` (line 125-128) + +**Reference**: `_deserializeSettings()` method (lines 39-143) shows how all settings are deserialized + +### Phase 2: Pigeon API Definition + +#### Step 2.1 - Add Pigeon API Methods +**File**: `packages/flutter_mozilla_components/pigeons/gecko.dart` + +**Location**: Find `GeckoEngineSettingsApi` interface (around line 1004-1015) + +**Changes Required**: +- Add method declaration before closing brace: + ```dart + void setUseExternalDownloadManager(bool enabled); + bool getUseExternalDownloadManager(); + ``` + +**Reference**: Similar to `setAppLinksMode` and `getAppLinksMode` methods (lines 1012-1014) + +**Important**: After modifying this file, must run `melos build-pigeons --no-select` to regenerate interfaces + +### Phase 3: Flutter Service Layer + +#### Step 3.1 - Update GeckoEngineSettingsService +**File**: `packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart` + +**Location**: After `getAppLinksMode()` method (around line 148-151) + +**Changes Required**: +- Add two methods to wrap Pigeon API calls: + ```dart + Future setUseExternalDownloadManager(bool enabled) { + return _api.setUseExternalDownloadManager(enabled); + } + + Future getUseExternalDownloadManager() { + return _api.getUseExternalDownloadManager(); + } + ``` + +**Reference**: Follow pattern of `setAppLinksMode()`/`getAppLinksMode()` methods (lines 144-150) + +#### Step 3.2 - Add Riverpod Provider +**File**: `app/lib/features/geckoview/features/browser/domain/providers.dart` + +**Location**: After `AppLinksModeNotifier` class (around line 395) + +**Changes Required**: +- Add new provider class: + ```dart + @Riverpod(keepAlive: true) + class ExternalDownloadManagerNotifier extends _$ExternalDownloadManagerNotifier { + final _service = GeckoEngineSettingsService(); + + Future setEnabled(bool enabled) async { + await _service.setUseExternalDownloadManager(enabled); + ref.invalidateSelf(); + } + + @override + Future build() { + return _service.getUseExternalDownloadManager(); + } + } + ``` + +**Reference**: Follow `AppLinksModeNotifier` pattern (lines 382-395) + +**Important**: After modifying this file, must run `melos build --no-select` to generate provider code + +### Phase 4: Native Implementation (Kotlin) + +#### Step 4.1 - Implement Pigeon API Methods +**File**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt` + +**Location**: After `getAppLinksMode()` method (around line 353) + +**Changes Required**: +- Add method implementations: + ```kotlin + override fun setUseExternalDownloadManager(enabled: Boolean) { + GlobalComponents.useExternalDownloadManager = enabled + } + + override fun getUseExternalDownloadManager(): Boolean { + return GlobalComponents.useExternalDownloadManager ?: false + } + ``` + +**Reference**: Follow `setAppLinksMode()`/`getAppLinksMode()` implementation pattern (lines 325-352) +**Note**: Unlike AppLinks which uses SharedPreferences, we use a global variable that Flutter controls + +#### Step 4.2 - Add Global State Variable +**File**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt` + +**Location**: After other engine settings state (around line 74) + +**Changes Required**: +- Add companion object variable: + ```kotlin + var useExternalDownloadManager: Boolean = false + ``` + +**Reference**: Similar to `engineSettingsApi` and `pullToRefreshEnabled` variables (lines 58, 61) + +#### Step 4.3 - Configure DownloadsFeature +**File**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt` + +**Location**: Find `downloadsFeature.set(...)` call (around line 271-293) + +**Changes Required**: +- Add `shouldForwardToThirdParties` parameter to `DownloadsFeature` constructor: + ```kotlin + downloadsFeature.set( + feature = DownloadsFeature( + // ... existing parameters ... + onNeedToRequestPermissions = { permissions -> + requestDownloadPermissionsLauncher.launch(permissions) + }, + shouldForwardToThirdParties = { + GlobalComponents.useExternalDownloadManager + }, + ), + owner = this, + view = view, + ) + ``` + +**Reference**: Fenix implementation at `fenix/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt:757-762` + +### Phase 5: UI Implementation + +#### Step 5.1 - Create Downloads Settings Section +**File**: `app/lib/features/settings/presentation/screens/privacy_security_settings.dart` + +**Location**: In the `build()` method's ListView children list (around line 50) + +**Changes Required**: +- Add `_DownloadsSection()` to children array after `_DataManagementSection()` +- Create new widget class: + ```dart + class _DownloadsSection extends StatelessWidget { + const _DownloadsSection(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + SettingSection(name: 'Downloads'), + _ExternalDownloadManagerTile(), + ], + ); + } + } + ``` + +**Reference**: Look at `_PrivacyModesSection` (lines 65-78) or `_TrackingProtectionSection` patterns + +#### Step 5.2 - Create Toggle Tile Widget +**File**: `app/lib/features/settings/presentation/screens/privacy_security_settings.dart` + +**Location**: Add after the new `_DownloadsSection` class + +**Changes Required**: +- Create `HookConsumerWidget` for the toggle: + ```dart + class _ExternalDownloadManagerTile extends HookConsumerWidget { + const _ExternalDownloadManagerTile(); + + @override + Widget build(BuildContext context) { + final useExternalDownloadManager = ref.watch( + generalSettingsWithDefaultsProvider.select((s) => s.useExternalDownloadManager), + ); + + return SettingToggleTile( + title: Text('Use external download manager'), + subtitle: Text('Forward downloads to apps like ADM, 1DM, AB DM'), + value: useExternalDownloadManager, + onChanged: (value) async { + await ref + .read(generalSettingsRepositoryProvider.notifier) + .updateSettings((s) => s.copyWith(useExternalDownloadManager: value)); + }, + ); + } + } + ``` + +**Reference**: Look at `_IncognitoModeSection` or other toggle implementations in the same file +**Pattern Reference**: `pullToRefreshEnabled` toggle at `app/lib/features/settings/presentation/screens/appearance_display_settings.dart:385-408` + +### Phase 6: Synchronization & Initialization + +#### Step 6.1 - Initial Synchronization +**Consideration**: When the app starts, we need to ensure the native side has the correct value. + +**Options**: +1. **Option A**: Add to initialization code in `app/lib/domain/services/app_initialization.dart` +2. **Option B**: Let the provider's `build()` method handle it naturally (recommended) +3. **Option C**: Add to `app/lib/features/geckoview/features/browser/domain/providers.dart` initialization + +**Recommended Approach**: Let the Riverpod provider handle it. When UI first renders, it will: +- Read from database via `generalSettingsRepositoryProvider` +- Provider's `build()` calls `getUseExternalDownloadManager()` from native +- The provider pattern will handle setting the value on native side when changed + +**Note**: Unlike AppLinks (which reads from SharedPreferences in native code), this pattern relies on Flutter being the source of truth and syncing to native. + +## Build Commands + +After completing implementation changes, run these commands in order: + +```bash +# 1. Regenerate Pigeon interfaces (after modifying gecko.dart) +melos build-pigeons --no-select + +# 2. Regenerate Riverpod providers and models (after modifying providers.dart or models) +melos build --no-select +``` + +## Testing Checklist + +- [ ] Setting toggle appears in Privacy & Security settings +- [ ] Toggle state persists across app restarts +- [ ] When enabled, downloads are forwarded to external apps +- [ ] When disabled, downloads use built-in manager +- [ ] Setting value is correctly stored in user.db +- [ ] Native side correctly reads value from Flutter +- [ ] DownloadsFeature uses correct shouldForwardToThirdParties value +- [ ] No SharedPreferences usage (requirement verified) + +## File Modification Summary + +| # | File | Phase | Change Type | +|---|------|-------|-------------| +| 1 | `app/lib/features/user/data/models/general_settings.dart` | 1.1 | Add model field | +| 2 | `app/lib/features/user/domain/repositories/general_settings.dart` | 1.2 | Add deserialization | +| 3 | `packages/flutter_mozilla_components/pigeons/gecko.dart` | 2.1 | Add Pigeon API methods | +| 4 | `packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart` | 3.1 | Add service wrapper methods | +| 5 | `app/lib/features/geckoview/features/browser/domain/providers.dart` | 3.2 | Add Riverpod provider | +| 6 | `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt` | 4.1 | Implement native methods | +| 7 | `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt` | 4.2 | Add global state variable | +| 8 | `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt` | 4.3 | Configure DownloadsFeature | +| 9 | `app/lib/features/settings/presentation/screens/privacy_security_settings.dart` | 5.1, 5.2 | Add UI widgets | + +## Important Notes + +### Design Decisions + +1. **No SharedPreferences in Native Code**: Unlike AppLinks which uses SharedPreferences, this feature uses a global variable set by Flutter. This ensures Flutter is the single source of truth. + +2. **Database as Storage**: Setting is stored in the database (user.db, setting table) following the same pattern as other GeneralSettings. + +3. **Provider Architecture**: Uses `@Riverpod(keepAlive: true)` to ensure the provider persists across widget rebuilds. + +4. **Async Provider**: The provider returns `Future` because native Pigeon calls are asynchronous. + +5. **Auto-Dispose Prevention**: `keepAlive: true` ensures the provider isn't disposed when no widgets are listening, which is important for maintaining sync state. + +### Mozilla Android Components Integration + +The `shouldForwardToThirdParties` callback in `DownloadsFeature` is invoked for each download request: +- When `true`: Download is forwarded to an external app via intent +- When `false`: Download is handled internally by FetchDownloadManager + +This is the exact same mechanism used in Fenix for the same feature. + +### Code Generation + +Both `melos build-pigeons --no-select` and `melos build --no-select` are required because: +- Pigeon generates platform channel interfaces +- Riverpod generates provider code +- JSON serialization generates model methods +- CopyWith extension generates copyWith methods + +Run these commands after modifying any annotated files. + +## Related Code References + +### Database Schema +**File**: `app/lib/features/user/data/database/definitions.drift:1-5` +- Setting table structure +- Uses Drift's `ANY` type for flexible value storage + +### DAO Pattern +**File**: `app/lib/features/user/data/database/daos/setting.dart:29-62` +- `updateSetting()` method +- `getAllSettingsOfPartitionKey()` method +- Pattern for partition-based settings + +### Settings UI Pattern +**File**: `app/lib/features/settings/presentation/screens/appearance_display_settings.dart:385-408` +- Example of toggle tile implementation +- Shows how to watch and update settings + +### Native Feature Integration +**File**: `packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt:60-74` +- Pattern for shouldOpenLinksInApp and shouldPromptOpenLinksInApp +- Similar to how shouldForwardToThirdParties will be used + +## Success Criteria + +Feature is complete when: +1. User can toggle "Use external download manager" in Privacy & Security settings +2. Toggle state persists in database (user.db, setting table) +3. When enabled, clicking download links prompts to open in external apps +4. When disabled, downloads use built-in download manager +5. Setting syncs correctly between Flutter and native layers +6. No SharedPreferences are used for this feature (verified requirement) +7. Code follows existing WebLibre patterns (GeneralSettings, AppLinks, etc.) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index 12b76110..87ef53db 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -287,6 +287,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit onNeedToRequestPermissions = { permissions -> requestDownloadPermissionsLauncher.launch(permissions) }, + shouldForwardToThirdParties = { + GlobalComponents.useExternalDownloadManager + }, ), owner = this, view = view, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 44e4bce7..c95ee676 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -57,6 +57,9 @@ object GlobalComponents { // Engine settings API for managing engine-specific settings var engineSettingsApi: GeckoEngineSettingsApiImpl? = null + // External download manager setting + var useExternalDownloadManager: Boolean = false + fun shouldOpenLinksInApp(): Boolean { return when (engineSettingsApi!!.getAppLinksMode()) { eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode.ALWAYS -> true diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt index 35a40385..c9f6c569 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt @@ -350,4 +350,12 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi { else -> AppLinksMode.ASK } } + + override fun setUseExternalDownloadManager(enabled: Boolean) { + GlobalComponents.useExternalDownloadManager = enabled + } + + override fun getUseExternalDownloadManager(): Boolean { + return GlobalComponents.useExternalDownloadManager + } } 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 cb5dd270..5443d4c3 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 @@ -3850,6 +3850,12 @@ interface GeckoEngineSettingsApi { */ fun setAppLinksMode(mode: AppLinksMode) fun getAppLinksMode(): AppLinksMode + /** + * Sets whether to use external download managers for downloads. + * When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. + */ + fun setUseExternalDownloadManager(enabled: Boolean) + fun getUseExternalDownloadManager(): Boolean companion object { /** The codec used by GeckoEngineSettingsApi. */ @@ -3947,6 +3953,39 @@ interface GeckoEngineSettingsApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setUseExternalDownloadManager$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.setUseExternalDownloadManager(enabledArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.getUseExternalDownloadManager$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getUseExternalDownloadManager()) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart index 5c2a5868..a78458b0 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart @@ -148,4 +148,14 @@ class GeckoEngineSettingsService { Future getAppLinksMode() { return _api.getAppLinksMode(); } + + /// Sets whether to use external download managers for downloads. + /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. + Future setUseExternalDownloadManager(bool enabled) { + return _api.setUseExternalDownloadManager(enabled); + } + + Future getUseExternalDownloadManager() { + return _api.getUseExternalDownloadManager(); + } } 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 f5db95ff..8673e583 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -4481,6 +4481,57 @@ class GeckoEngineSettingsApi { return (pigeonVar_replyList[0] as AppLinksMode?)!; } } + + /// Sets whether to use external download managers for downloads. + /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. + Future setUseExternalDownloadManager(bool enabled) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setUseExternalDownloadManager$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?; + 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 { + return; + } + } + + Future getUseExternalDownloadManager() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.getUseExternalDownloadManager$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + 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?)!; + } + } } class GeckoSessionApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 1ae98adf..c4dca1e1 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1012,6 +1012,12 @@ abstract class GeckoEngineSettingsApi { void setAppLinksMode(AppLinksMode mode); AppLinksMode getAppLinksMode(); + + /// Sets whether to use external download managers for downloads. + /// When enabled, downloads are forwarded to third-party apps like ADM, 1DM, AB DM. + void setUseExternalDownloadManager(bool enabled); + + bool getUseExternalDownloadManager(); } @HostApi()