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 _jobs = {}; // keyed by remotePath final Map _byTaskId = {}; final Map _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> 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 _completions = StreamController.broadcast(); Stream 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 pendingOpen = ValueNotifier(null); bool _initialized = false; StreamSubscription? _updatesSub; /// Wires up the native downloader once. Safe to call repeatedly. Future 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, ); // Deliberately no notification-permission request here: it would fire the // OS prompt at cold start (before login), which is exactly what we moved // into the guided first-Talk-visit flow (see maybePromptTalkNotifications). // Downloads still work without it; their progress notifications simply // appear once the same POST_NOTIFICATIONS permission is granted there. } /// 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 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 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 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 _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 _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 _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; final ref = map['ref'] as Map?; 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; } }