added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
+372
View File
@@ -0,0 +1,372 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart' as bd;
import 'package:flutter/foundation.dart';
import '../../api/marianumcloud/webdav/webdav_api.dart';
import '../../model/account_data.dart';
import '../../notification/notification_service.dart';
import '../../share_intent/remote_file_ref.dart';
import 'download_job.dart';
/// Central registry for file downloads, shared between the Files list and Talk
/// chat bubbles. Backed by `background_downloader` so downloads run in a native
/// foreground service (Android) / URLSession background session (iOS) and
/// therefore keep running — with a system progress notification — while the app
/// is backgrounded or the user works elsewhere, even for large files.
///
/// Jobs are keyed by remote (WebDAV) path so a file triggered from multiple
/// screens reuses one job. The in-memory registry is not rebuilt across a full
/// app restart; a still-running native download keeps going and its completion
/// notification stays tappable (rebuilt on demand in [_onNotificationTap]).
class DownloadManager {
DownloadManager._();
static final DownloadManager instance = DownloadManager._();
/// Callback group for our tasks — scopes the notification-tap callback so it
/// only fires for downloads we started.
static const _group = 'mm_downloads';
/// Files land in the app's private temp area under this subdirectory, mirror-
/// ing the previous behaviour. Export to the device happens via the viewer's
/// "Teilen"/"Speichern" actions.
static const _directory = 'downloads';
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
final Map<String, DownloadJob> _byTaskId = {};
final Map<String, bd.DownloadTask> _taskById = {};
/// All jobs the user should currently see in the downloads tray/overview:
/// everything that is in progress or finished-but-not-yet-opened (failed
/// downloads linger so the user can retry; cancelled ones drop out).
final ValueNotifier<List<DownloadJob>> visibleJobs = ValueNotifier(const []);
/// Emits whenever a job reaches [DownloadDone]. The downloads tray listens to
/// decide between auto-opening (single download) and parking it in the
/// overview (parallel downloads).
final StreamController<DownloadJob> _completions =
StreamController<DownloadJob>.broadcast();
Stream<DownloadJob> get completions => _completions.stream;
/// Set when the user taps a completion notification; consumed by the
/// downloads tray to open the file (also covers cold-start taps).
final ValueNotifier<DownloadJob?> pendingOpen = ValueNotifier(null);
bool _initialized = false;
StreamSubscription<bd.TaskUpdate>? _updatesSub;
/// Wires up the native downloader once. Safe to call repeatedly.
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;
_updatesSub = bd.FileDownloader().updates.listen(_onUpdate);
bd.FileDownloader().registerCallbacks(
group: _group,
taskNotificationTapCallback: _onNotificationTap,
);
// Run larger downloads in a native foreground service: this lifts the
// default 9-minute WorkManager timeout and greatly improves the odds of a
// big file finishing while the app sits in the background. Smaller files
// stay on the lighter WorkManager path. Requires the running notification
// configured below + the manifest FOREGROUND_SERVICE(_DATA_SYNC) entries.
await bd.FileDownloader().configure(
androidConfig: (bd.Config.runInForegroundIfFileLargerThan, 10),
);
bd.FileDownloader().configureNotification(
running: const bd.TaskNotification(
'{filename}',
'Wird heruntergeladen … {progress}',
),
complete: const bd.TaskNotification(
'{filename}',
'Fertig tippen zum Öffnen',
),
error: const bd.TaskNotification('{filename}', 'Download fehlgeschlagen'),
progressBar: true,
// We route taps into the in-app viewer ourselves.
tapOpensFile: false,
);
// Notification permission is normally already granted via the FCM flow at
// login; request best-effort so downloads on a fresh install still notify.
try {
await bd.FileDownloader().permissions.request(
bd.PermissionType.notifications,
);
} on Object catch (e) {
debugPrint('DownloadManager: notification permission request failed: $e');
}
}
/// Active or recently finished job for [remotePath], or null if none.
DownloadJob? jobFor(String remotePath) => _jobs[remotePath];
/// Returns the existing in-flight job for [remotePath], otherwise enqueues a
/// new one. Caller listens on [DownloadJob.status] for inline UI.
Future<DownloadJob> start({
required String remotePath,
required String name,
RemoteFileRef? remoteFile,
}) async {
await initialize();
final existing = _jobs[remotePath];
if (existing != null && !existing.isFinished) return existing;
if (existing != null) _remove(existing);
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
final task = bd.DownloadTask(
url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
headers: AccountData().authHeaders(),
filename: name,
baseDirectory: bd.BaseDirectory.temporary,
directory: _directory,
group: _group,
updates: bd.Updates.statusAndProgress,
allowPause: true,
metaData: _encodeMeta(remotePath, name, remoteFile),
);
final job = DownloadJob(
remotePath: remotePath,
name: name,
remoteFile: remoteFile,
)..taskId = task.taskId;
_jobs[remotePath] = job;
_byTaskId[task.taskId] = job;
_taskById[task.taskId] = task;
_refreshVisible();
final ok = await bd.FileDownloader().enqueue(task);
if (!ok && !job.isFinished) {
_fail(job, 'Download konnte nicht gestartet werden.');
}
return job;
}
/// Cancels an in-flight download. The native task is cancelled and the job
/// transitions to [DownloadCancelled] (removed from the tray).
Future<void> cancel(DownloadJob job) async {
final taskId = job.taskId;
if (taskId != null) {
await bd.FileDownloader().cancelTaskWithId(taskId);
}
if (!job.isFinished) job.status.value = const DownloadCancelled();
_remove(job);
}
/// Re-enqueues a failed job under the same remote path.
Future<DownloadJob> retry(DownloadJob job) {
_remove(job);
return start(
remotePath: job.remotePath,
name: job.name,
remoteFile: job.remoteFile,
);
}
/// Marks a finished job as opened and drops it from the tray. Also dismisses
/// the lingering "download complete" system notification for that file.
void markOpened(DownloadJob job) {
job.opened = true;
_dismissNotification(job);
_remove(job);
}
/// Cancels the task's completion notification. background_downloader derives
/// its Android notification id from the Java `String.hashCode()` of the task
/// id, which we reproduce to target the exact notification. Android-only —
/// iOS uses a different identifier scheme.
void _dismissNotification(DownloadJob job) {
final taskId = job.taskId;
if (taskId == null || !Platform.isAndroid) return;
final id = _androidNotificationId(taskId);
unawaited(
NotificationService().flutterLocalNotificationsPlugin
.cancel(id: id)
.catchError((Object _) {}),
);
}
/// Java/Kotlin `String.hashCode()` (32-bit signed) — matches the id
/// background_downloader posts its notification under.
int _androidNotificationId(String taskId) {
var hash = 0;
for (final unit in taskId.codeUnits) {
hash = (31 * hash + unit) & 0xFFFFFFFF;
}
if (hash >= 0x80000000) hash -= 0x100000000;
return hash;
}
/// Removes a finished job from the tray without opening (swipe-to-dismiss).
void dismiss(DownloadJob job) => _remove(job);
/// Clears the whole tray at once (swipe the chip away): cancels anything still
/// running and drops every job. The registry empties synchronously so the UI
/// updates immediately; native cancellations fire in the background.
void clearAll() {
for (final job in _jobs.values.toList()) {
final taskId = job.taskId;
if (!job.isFinished) {
if (taskId != null) {
unawaited(bd.FileDownloader().cancelTaskWithId(taskId));
}
// Notify inline listeners (list/bubble) so their progress bars clear
// instead of freezing at the last percent.
job.status.value = const DownloadCancelled();
}
_jobs.remove(job.remotePath);
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
scheduleMicrotask(job.dispose);
}
_refreshVisible();
}
// --- native update handling ------------------------------------------------
void _onUpdate(bd.TaskUpdate update) {
final job = _byTaskId[update.task.taskId];
if (job == null || job.isFinished) return;
switch (update) {
case bd.TaskStatusUpdate():
switch (update.status) {
case bd.TaskStatus.complete:
unawaited(_complete(job, update.task));
case bd.TaskStatus.failed:
case bd.TaskStatus.notFound:
_fail(
job,
update.exception?.description ??
(update.status == bd.TaskStatus.notFound
? 'Datei auf dem Server nicht gefunden.'
: 'Download fehlgeschlagen.'),
);
case bd.TaskStatus.canceled:
job.status.value = const DownloadCancelled();
_remove(job);
case bd.TaskStatus.enqueued:
case bd.TaskStatus.running:
case bd.TaskStatus.waitingToRetry:
case bd.TaskStatus.paused:
break; // progress is delivered separately
}
case bd.TaskProgressUpdate():
// Negative values are sentinels (failed/canceled/…) handled via status.
final progress = update.progress;
job.status.value = DownloadInProgress(
progress >= 0 && progress <= 1 ? progress * 100 : 0,
);
}
}
Future<void> _complete(DownloadJob job, bd.Task task) async {
if (job.isFinished) return;
final path = await task.filePath();
if (job.isFinished) return;
job.status.value = DownloadDone(path);
_refreshVisible();
_completions.add(job);
}
void _fail(DownloadJob job, String message) {
if (job.isFinished) return;
job.status.value = DownloadFailed(message);
_refreshVisible();
}
Future<void> _onNotificationTap(
bd.Task task,
bd.NotificationType notificationType,
) async {
if (notificationType != bd.NotificationType.complete) return;
final job = _byTaskId[task.taskId] ?? await _rebuildJob(task);
if (job == null) return;
pendingOpen.value = job;
}
/// Rebuilds a finished [DownloadJob] straight from a native task — used when a
/// completion notification is tapped after the app (and thus the in-memory
/// registry) was gone.
Future<DownloadJob?> _rebuildJob(bd.Task task) async {
try {
final meta = _decodeMeta(task.metaData);
final path = await task.filePath();
final job = DownloadJob(
remotePath: meta?.remotePath ?? '',
name: task.filename,
remoteFile: meta?.remoteFile,
)..taskId = task.taskId;
job.status.value = DownloadDone(path);
return job;
} on Object catch (e) {
debugPrint('DownloadManager: could not rebuild tapped task: $e');
return null;
}
}
// --- bookkeeping -----------------------------------------------------------
void _remove(DownloadJob job) {
_jobs.remove(job.remotePath);
final taskId = job.taskId;
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
_refreshVisible();
scheduleMicrotask(job.dispose);
}
void _refreshVisible() {
visibleJobs.value = _jobs.values
.where((j) => !j.opened && !j.isCancelled)
.toList(growable: false);
}
// --- metadata (remote ref) serialisation ----------------------------------
String _encodeMeta(String remotePath, String name, RemoteFileRef? ref) =>
jsonEncode({
'remotePath': remotePath,
'name': name,
if (ref != null)
'ref': {
'path': ref.path,
'name': ref.name,
'fileId': ref.fileId,
'hasPreview': ref.hasPreview,
},
});
({String remotePath, RemoteFileRef? remoteFile})? _decodeMeta(String meta) {
if (meta.isEmpty) return null;
final map = jsonDecode(meta) as Map<String, dynamic>;
final ref = map['ref'] as Map<String, dynamic>?;
return (
remotePath: (map['remotePath'] as String?) ?? '',
remoteFile: ref == null
? null
: RemoteFileRef(
path: ref['path'] as String,
name: ref['name'] as String,
fileId: ref['fileId'] as int?,
hasPreview: ref['hasPreview'] as bool?,
),
);
}
@visibleForTesting
void debugDispose() {
_updatesSub?.cancel();
_initialized = false;
}
}