361 lines
13 KiB
Dart
361 lines
13 KiB
Dart
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;
|
||
}
|
||
}
|