diff --git a/apps/weblibre/android/app/src/main/AndroidManifest.xml b/apps/weblibre/android/app/src/main/AndroidManifest.xml
index c758d1c8..08bc99b2 100644
--- a/apps/weblibre/android/app/src/main/AndroidManifest.xml
+++ b/apps/weblibre/android/app/src/main/AndroidManifest.xml
@@ -36,6 +36,7 @@
+
diff --git a/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart b/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart
index ac5fbbc9..c8b0e811 100644
--- a/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart
+++ b/apps/weblibre/lib/features/geckoview/features/history/presentation/screens/history.dart
@@ -45,6 +45,7 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
+import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class Section extends MultiSliver {
static final _datePattern = DateFormat.MMMd().addPattern('Hm');
@@ -499,6 +500,34 @@ class HistoryScreen extends HookConsumerWidget {
onTap: (item) async {
if (selectedItems.value.isNotEmpty) {
toggleSelected(item);
+ } else if (isDownloadsMode) {
+ final filePath = item.title;
+ final file = filePath.mapNotNull(File.new);
+
+ if (file == null ||
+ await file.exists() != true) {
+ if (context.mounted) {
+ ui_helper.showErrorMessage(
+ context,
+ 'Downloaded file not found',
+ );
+ }
+ return;
+ }
+
+ final opened = await GeckoDownloadsService()
+ .openDownloadedFile(
+ fileName: p.basename(file.path),
+ directoryPath: file.parent.path,
+ contentType: item.previewImageUrl,
+ );
+
+ if (!opened && context.mounted) {
+ ui_helper.showErrorMessage(
+ context,
+ 'Could not open downloaded file',
+ );
+ }
} else {
await ref
.read(tabRepositoryProvider.notifier)
diff --git a/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart b/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart
index abd590f3..5d20aa00 100644
--- a/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart
+++ b/apps/weblibre/lib/features/geckoview/features/preferences/data/repositories/preference_settings.g.dart
@@ -441,7 +441,7 @@ final class UnifiedPreferenceSettingsRepositoryProvider
}
String _$unifiedPreferenceSettingsRepositoryHash() =>
- r'c544b5815ec676ddb6cde1ffeb8c9ef4a7dc4469';
+ r'f2153c826e2cba308cf3e8bdfe371e0b6b3e7dbd';
final class UnifiedPreferenceSettingsRepositoryFamily extends $Family
with
@@ -554,7 +554,7 @@ final class PreferenceSettingsGroupRepositoryProvider
}
String _$preferenceSettingsGroupRepositoryHash() =>
- r'c8335b39c4c9fa95c4d415231b103a2feff97d26';
+ r'ae2b8553b895945d2a5c44a8ed24b8bbb00bb985';
final class PreferenceSettingsGroupRepositoryFamily extends $Family
with
diff --git a/apps/weblibre/lib/presentation/main_app.dart b/apps/weblibre/lib/presentation/main_app.dart
index 78b1fa56..a9a17132 100644
--- a/apps/weblibre/lib/presentation/main_app.dart
+++ b/apps/weblibre/lib/presentation/main_app.dart
@@ -18,9 +18,12 @@
* along with this program. If not, see .
*/
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/providers/router.dart';
import 'package:weblibre/domain/services/app_initialization.dart';
+import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
@@ -99,7 +102,9 @@ class MainApp extends HookConsumerWidget {
disableAnimations: disableAnimations,
child: _SyncEventListener(
child: _SandboxCaptureErrorListener(
- child: child ?? const SizedBox.shrink(),
+ child: _DownloadStoppedListener(
+ child: child ?? const SizedBox.shrink(),
+ ),
),
),
);
@@ -142,6 +147,65 @@ class MainApp extends HookConsumerWidget {
}
}
+class _DownloadStoppedListener extends HookConsumerWidget {
+ final Widget child;
+
+ const _DownloadStoppedListener({required this.child});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final downloadStoppedEvents = ref.watch(
+ eventServiceProvider.select((service) => service.downloadStoppedEvents),
+ );
+
+ useOnStreamChange(
+ downloadStoppedEvents,
+ onData: (download) {
+ switch (download.status) {
+ case DownloadStatus.completed:
+ ui_helper.showInfoMessage(
+ context,
+ 'Download completed',
+ duration: const Duration(seconds: 6),
+ action: SnackBarAction(
+ label: 'Open',
+ onPressed: () async {
+ final opened = await GeckoDownloadsService()
+ .openDownloadedFile(
+ fileName: download.fileName ?? '',
+ directoryPath: download.directoryPath ?? '',
+ contentType: download.contentType,
+ );
+
+ if (!opened && context.mounted) {
+ ui_helper.showErrorMessage(
+ context,
+ 'Could not open downloaded file',
+ );
+ }
+ },
+ ),
+ );
+ case DownloadStatus.failed:
+ ui_helper.showErrorMessage(
+ context,
+ 'Download failed: ${download.fileName ?? download.url}',
+ persist: true,
+ );
+ case DownloadStatus.initiated:
+ case DownloadStatus.downloading:
+ case DownloadStatus.paused:
+ case DownloadStatus.cancelled:
+ case null:
+ break;
+ }
+ },
+ );
+
+ return child;
+ }
+}
+
MediaQueryData applyAppMediaQueryOverrides({
required MediaQueryData mediaQuery,
required double uiScaleFactor,
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 229fca29..b34064b5 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
@@ -26,7 +26,9 @@ import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.addons.WebExtensionPromptFeature
import eu.weblibre.flutter_mozilla_components.databinding.FragmentBrowserBinding
+import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
+import eu.weblibre.flutter_mozilla_components.ext.toPigeonDownloadState
import eu.weblibre.flutter_mozilla_components.feature.BrowserHandlingScrollFeature
import eu.weblibre.flutter_mozilla_components.feature.KeyboardVisibilityFeature
import eu.weblibre.flutter_mozilla_components.feature.ReadabilityExtractFeature
@@ -320,6 +322,15 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
fragmentManager = childFragmentManager,
onDownloadStopped = { download, id, status ->
Logger.debug("Download done. ID#$id $download with status $status")
+ if (
+ status == mozilla.components.browser.state.state.content.DownloadState.Status.COMPLETED ||
+ status == mozilla.components.browser.state.state.content.DownloadState.Status.FAILED
+ ) {
+ components.flutterEvents.onDownloadStopped(
+ EventSequence.next(),
+ download.toPigeonDownloadState(status),
+ ) { _ -> }
+ }
},
downloadFileUtils = DefaultDownloadFileUtils(
context = components.profileApplicationContext,
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt
index 17d6eeda..62a6b857 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoDownloadsApiImpl.kt
@@ -15,6 +15,7 @@ import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.CopyInternetResourceAction
import mozilla.components.browser.state.action.ShareResourceAction
import mozilla.components.browser.state.state.content.ShareResourceState
+import mozilla.components.support.utils.DefaultDownloadFileUtils
import java.util.UUID
class GeckoDownloadsApiImpl : GeckoDownloadsApi {
@@ -39,6 +40,23 @@ class GeckoDownloadsApiImpl : GeckoDownloadsApi {
components.core.store.dispatch(ShareResourceAction.AddShareAction(tabId, state.toMozillaShareInternetResourceState()))
}
+ override fun openDownloadedFile(
+ fileName: String,
+ directoryPath: String,
+ contentType: String?,
+ ): Boolean {
+ return DefaultDownloadFileUtils(
+ context = components.profileApplicationContext,
+ downloadLocation = {
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path
+ },
+ ).openFile(
+ fileName = fileName,
+ directoryPath = directoryPath,
+ contentType = contentType,
+ )
+ }
+
private fun ShareInternetResourceState.toMozillaShareInternetResourceState(): ShareResourceState.InternetResource {
return ShareResourceState.InternetResource(
url = url,
@@ -84,4 +102,4 @@ class GeckoDownloadsApiImpl : GeckoDownloadsApi {
}
}
-}
\ No newline at end of file
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/DownloadState.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/DownloadState.kt
new file mode 100644
index 00000000..7daa00c9
--- /dev/null
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ext/DownloadState.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.ext
+
+import eu.weblibre.flutter_mozilla_components.pigeons.DownloadState as PigeonDownloadState
+import eu.weblibre.flutter_mozilla_components.pigeons.DownloadStatus as PigeonDownloadStatus
+import mozilla.components.browser.state.state.content.DownloadState as MozillaDownloadState
+
+fun MozillaDownloadState.toPigeonDownloadState(
+ statusOverride: MozillaDownloadState.Status = status,
+): PigeonDownloadState =
+ PigeonDownloadState(
+ url = url,
+ fileName = fileName,
+ contentType = contentType,
+ contentLength = contentLength,
+ currentBytesCopied = currentBytesCopied,
+ status = statusOverride.toPigeonDownloadStatus(),
+ userAgent = userAgent,
+ destinationDirectory = null,
+ directoryPath = directoryPath,
+ referrerUrl = referrerUrl,
+ skipConfirmation = skipConfirmation,
+ openInApp = openInApp,
+ id = id,
+ sessionId = sessionId,
+ private = private,
+ createdTime = createdTime,
+ notificationId = notificationId?.toLong(),
+ )
+
+private fun MozillaDownloadState.Status.toPigeonDownloadStatus(): PigeonDownloadStatus =
+ when (this) {
+ MozillaDownloadState.Status.INITIATED -> PigeonDownloadStatus.INITIATED
+ MozillaDownloadState.Status.DOWNLOADING -> PigeonDownloadStatus.DOWNLOADING
+ MozillaDownloadState.Status.PAUSED -> PigeonDownloadStatus.PAUSED
+ MozillaDownloadState.Status.CANCELLED -> PigeonDownloadStatus.CANCELLED
+ MozillaDownloadState.Status.FAILED -> PigeonDownloadStatus.FAILED
+ MozillaDownloadState.Status.COMPLETED -> PigeonDownloadStatus.COMPLETED
+ }
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 3e08acb8..04357acf 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
@@ -9046,6 +9046,23 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
+ fun onDownloadStopped(sequenceArg: Long, stateArg: DownloadState, callback: (Result) -> Unit)
+{
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onDownloadStopped$separatedMessageChannelSuffix"
+ val channel = BasicMessageChannel(binaryMessenger, channelName, codec)
+ channel.send(listOf(sequenceArg, stateArg)) {
+ if (it is List<*>) {
+ if (it.size > 1) {
+ callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
+ } else {
+ callback(Result.success(Unit))
+ }
+ } else {
+ callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
+ }
+ }
+ }
fun onManifestUpdate(sequenceArg: Long, tabIdArg: String, manifestArg: PwaManifest?, callback: (Result) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
@@ -10537,6 +10554,7 @@ interface GeckoDownloadsApi {
fun requestDownload(tabId: String, state: DownloadState)
fun copyInternetResource(tabId: String, state: ShareInternetResourceState)
fun shareInternetResource(tabId: String, state: ShareInternetResourceState)
+ fun openDownloadedFile(fileName: String, directoryPath: String, contentType: String?): Boolean
companion object {
/** The codec used by GeckoDownloadsApi. */
@@ -10604,6 +10622,25 @@ interface GeckoDownloadsApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoDownloadsApi.openDownloadedFile$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val fileNameArg = args[0] as String
+ val directoryPathArg = args[1] as String
+ val contentTypeArg = args[2] as String?
+ val wrapped: List = try {
+ listOf(api.openDownloadedFile(fileNameArg, directoryPathArg, contentTypeArg))
+ } catch (exception: Throwable) {
+ GeckoPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } 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 fee6bcdf..7b816e62 100644
--- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart
+++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart
@@ -64,6 +64,8 @@ export 'src/pigeons/gecko.g.dart'
DocumentType,
DohSettings,
DohSettingsMode,
+ DownloadState,
+ DownloadStatus,
EmailHitResult,
FrecencyThresholdOption,
GeckoDeleteBrowsingDataController,
diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_downloads.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_downloads.dart
index a959e8fa..b90b084b 100644
--- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_downloads.dart
+++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_downloads.dart
@@ -72,4 +72,12 @@ class GeckoDownloadsService {
),
);
}
+
+ Future openDownloadedFile({
+ required String fileName,
+ required String directoryPath,
+ String? contentType,
+ }) {
+ return _api.openDownloadedFile(fileName, directoryPath, contentType);
+ }
}
diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart
index c07bdf0c..9719cfc8 100644
--- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart
+++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_event.dart
@@ -23,6 +23,7 @@ typedef ScrollEvent = ({String tabId, int scrollY});
typedef ManifestUpdateEvent = ({String tabId, PwaManifest? manifest});
typedef TabTranslationEvent = ({String tabId, TabTranslationStateData state});
typedef TranslationEngineEvent = TranslationEngineStateData;
+typedef DownloadStoppedEvent = DownloadState;
class GeckoEventService extends GeckoStateEvents {
// Stream controllers
@@ -48,6 +49,7 @@ class GeckoEventService extends GeckoStateEvents {
final _tabAddedSubject = PublishSubject();
final _mlProgressSubject = PublishSubject();
+ final _downloadStoppedSubject = PublishSubject();
final _manifestUpdateSubject = PublishSubject();
final _translationEngineSubject = BehaviorSubject();
final _tabTranslationSubject = ReplaySubject();
@@ -77,6 +79,8 @@ class GeckoEventService extends GeckoStateEvents {
Stream get tabAddedStream => _tabAddedSubject.stream;
Stream get mlProgressEvents => _mlProgressSubject.stream;
+ Stream get downloadStoppedEvents =>
+ _downloadStoppedSubject.stream;
Stream get manifestUpdateEvents =>
_manifestUpdateSubject.stream;
ValueStream get translationEngineEvents =>
@@ -218,6 +222,11 @@ class GeckoEventService extends GeckoStateEvents {
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
}
+ @override
+ void onDownloadStopped(int sequence, DownloadState state) {
+ _downloadStoppedSubject.addWhenMoreRecent(sequence, state.id, state);
+ }
+
@override
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest) {
_manifestUpdateSubject.addWhenMoreRecent(sequence, tabId, (
@@ -276,6 +285,7 @@ class GeckoEventService extends GeckoStateEvents {
await _siteAssignementSubject.close();
await _proxyLoadErrorSubject.close();
await _mlProgressSubject.close();
+ await _downloadStoppedSubject.close();
await _manifestUpdateSubject.close();
await _translationEngineSubject.close();
await _tabTranslationSubject.close();
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 3b6a58dd..4e7cc454 100644
--- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
+++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
- List