Files
Client/lib/utils/downloads/download_manager.dart
T

361 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = {};
/// 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,
);
// 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<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;
_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);
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);
_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;
}
}