diff --git a/lib/widget/downloads/download_tray.dart b/lib/widget/downloads/download_tray.dart index f120849..040a956 100644 --- a/lib/widget/downloads/download_tray.dart +++ b/lib/widget/downloads/download_tray.dart @@ -7,6 +7,7 @@ import '../../utils/downloads/download_job.dart'; import '../../utils/downloads/download_manager.dart'; import '../../utils/haptics.dart'; import 'downloads_sheet.dart'; +import 'stale_download_guard.dart'; /// Decides whether a just-finished download should open straight in the viewer. /// @@ -198,9 +199,13 @@ class _DownloadTrayHostState extends State void _openJob(DownloadJob job) { final path = job.localPath; - _manager.markOpened(job); final ctx = AppRoutes.overlayContext; - if (path == null || ctx == null) return; + if (path == null || ctx == null) { + _manager.markOpened(job); + return; + } + if (!ensureDownloadStillExists(ctx, job)) return; + _manager.markOpened(job); Haptics.success(); AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile); } diff --git a/lib/widget/downloads/downloads_sheet.dart b/lib/widget/downloads/downloads_sheet.dart index 8dc48a5..847bc17 100644 --- a/lib/widget/downloads/downloads_sheet.dart +++ b/lib/widget/downloads/downloads_sheet.dart @@ -10,6 +10,7 @@ import '../../view/pages/files/data/file_type_icon.dart'; import '../centered_leading.dart'; import '../details_bottom_sheet.dart'; import '../info_dialog.dart'; +import 'stale_download_guard.dart'; /// Overview of all active and finished-but-unopened downloads. Lets the user /// open/switch between finished files, cancel running ones and retry failures. @@ -86,8 +87,11 @@ class _DownloadsListState extends State<_DownloadsList> { void _open(DownloadJob job) { final path = job.localPath; if (path == null) return; - Haptics.success(); + // Pop the sheet first so a stale-file dialog lands on the underlying + // screen instead of an emptied sheet. Navigator.of(widget.sheetContext).pop(); + if (!ensureDownloadStillExists(widget.rootContext, job)) return; + Haptics.success(); DownloadManager.instance.markOpened(job); AppRoutes.openFileViewer( widget.rootContext, diff --git a/lib/widget/downloads/stale_download_guard.dart b/lib/widget/downloads/stale_download_guard.dart new file mode 100644 index 0000000..457d3b2 --- /dev/null +++ b/lib/widget/downloads/stale_download_guard.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../utils/downloads/download_job.dart'; +import '../../utils/downloads/download_manager.dart'; +import '../confirm_dialog.dart'; +import '../info_dialog.dart'; + +/// Verifies a finished download's cache file is still on disk before opening. +/// +/// Downloads live in the app's temp dir, which Android may clear at any time +/// (storage pressure, "Cache leeren") — while completion notifications and +/// tray entries stay tappable indefinitely. Returns true when the file exists; +/// otherwise drops the stale job (incl. its notification) and offers a +/// re-download when the remote path is known (it isn't for notification taps +/// whose task metadata got lost). +bool ensureDownloadStillExists(BuildContext context, DownloadJob job) { + final path = job.localPath; + if (path == null || File(path).existsSync()) return true; + + DownloadManager.instance.markOpened(job); + if (job.remotePath.isNotEmpty) { + ConfirmDialog( + title: 'Datei nicht mehr verfügbar', + content: + 'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?', + confirmButton: 'Herunterladen', + onConfirm: () => unawaited(DownloadManager.instance.retry(job)), + ).asDialog(context); + } else { + InfoDialog.show( + context, + 'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.', + title: 'Datei nicht mehr verfügbar', + ); + } + return false; +} diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index ac9ed2f..e659ead 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -14,8 +14,11 @@ import 'package:share_plus/share_plus.dart'; import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../utils/downloads/download_manager.dart'; import 'app_progress_indicator.dart'; +import 'async_action_button.dart'; import 'centered_leading.dart'; +import 'confirm_dialog.dart'; import 'file_viewer/code_line.dart'; import 'file_viewer/deferred_pdf_viewer.dart'; import 'file_viewer/file_kind.dart'; @@ -82,9 +85,43 @@ class _FileViewerState extends State { super.dispose(); } + /// Android may clear the cache dir behind an open viewer at any time — + /// verify the file is still there before handing its path to an action. + bool _ensureLocalFile() { + if (File(widget.path).existsSync()) return true; + final remote = widget.remoteFile; + if (remote == null) { + InfoDialog.show( + context, + 'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.', + title: 'Datei nicht mehr verfügbar', + ); + return false; + } + ConfirmDialog( + title: 'Datei nicht mehr verfügbar', + content: + 'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?', + confirmButton: 'Herunterladen', + onConfirm: () { + // Pop the viewer before starting so the fresh download auto-opens. + Navigator.of(context).pop(); + unawaited( + DownloadManager.instance.start( + remotePath: remote.path, + name: remote.name, + remoteFile: remote, + ), + ); + }, + ).asDialog(context); + return false; + } + Future _handleAction(FileViewingActions value) async { switch (value) { case FileViewingActions.openExternal: + if (!_ensureLocalFile()) return; AppRoutes.openFileViewer( context, widget.path, @@ -99,16 +136,21 @@ class _FileViewerState extends State { AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!); break; case FileViewingActions.share: + if (!_ensureLocalFile()) return; unawaited( - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context), + runWithErrorDialog( + context, + () => SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), ), ), ); break; case FileViewingActions.save: + if (!_ensureLocalFile()) return; try { final source = File(widget.path); final size = await source.length();