added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user