feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the Settings "change storage" flow and pm move-package. Adoptable storage relocates the private data directory with the APK, so downloads follow the app onto a USB drive adopted by an Android TV. Moving the app changes the private data directory, which invalidated any download task already enqueued: those pinned BaseDirectory.root plus an absolute directory that background_downloader persists verbatim, so a queued or paused download resumed writing to a volume the app no longer owns. Enqueue app-storage targets against the base directory the downloader re-resolves from the live app context instead, and drop the tasks and records a previous location left behind so the download restarts under the current one. That sweep runs before the downloader is wired up, because initialization delivers statuses accumulated while suspended — which can mark the row failed, and a failed row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues every killed record it finds, stale absolute directory included. Compare paths by containment rather than by string prefix while making a stored path relative. A custom download root that merely starts with the base directory's name is a sibling the app does not own, and stripping it re-rooted the download inside app storage. close #1794
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
|
<!-- android:installLocation="auto" is what makes the app eligible to be moved to adoptable
|
||||||
|
storage (a USB drive adopted by an Android TV, an adopted SD card), which relocates the
|
||||||
|
private data directory — and therefore downloads — along with the APK. Plezy declares none
|
||||||
|
of the components this is unsafe for: no app widget, IME, live wallpaper, device admin,
|
||||||
|
accessibility service, sync adapter or account authenticator. -->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:installLocation="auto">
|
||||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ typedef _NativeTaskForId = Future<Task?> Function(String taskId);
|
|||||||
typedef _NativeResumeTask = Future<bool> Function(DownloadTask task);
|
typedef _NativeResumeTask = Future<bool> Function(DownloadTask task);
|
||||||
typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri});
|
typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri});
|
||||||
|
|
||||||
|
/// The background_downloader entry points the recovery path drives. Injected as a whole
|
||||||
|
/// in tests so relocated-storage recovery can be exercised without platform channels.
|
||||||
|
typedef NativeDownloaderOps = ({
|
||||||
|
Future<List<Task>> Function() allTasks,
|
||||||
|
Future<List<TaskRecord>> Function() allRecords,
|
||||||
|
Future<void> Function(String taskId) deleteRecord,
|
||||||
|
Future<bool> Function(Iterable<String> taskIds) cancelTaskIds,
|
||||||
|
Future<int> Function() cleanUpOrphanedTempFiles,
|
||||||
|
Future<(List<Task>, List<Task>)> Function() rescheduleKilledTasks,
|
||||||
|
});
|
||||||
|
|
||||||
typedef NativeTaskPartition = ({List<Task> current, List<Task> stale});
|
typedef NativeTaskPartition = ({List<Task> current, List<Task> stale});
|
||||||
|
|
||||||
typedef DownloadLocationSnapshot = ({String? path, String? type});
|
typedef DownloadLocationSnapshot = ({String? path, String? type});
|
||||||
@@ -61,6 +72,40 @@ NativeTaskPartition partitionNativeTasks(Iterable<Task> tasks, String? currentTa
|
|||||||
return (current: current, stale: stale);
|
return (current: current, stale: stale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [task] writes into a directory left behind by a previous location of the
|
||||||
|
/// app's private storage.
|
||||||
|
///
|
||||||
|
/// A [BaseDirectory.root] task carries its whole target directory in the downloader's
|
||||||
|
/// own persisted store, so one enqueued before the app moved to adoptable storage (or
|
||||||
|
/// before an iOS container UUID change) resumes writing where the app owns nothing.
|
||||||
|
/// Legitimate root-anchored tasks sit under [baseAppDirPath] or under the configured
|
||||||
|
/// [customRootPath], which does not move with the app; anything else is a leftover with
|
||||||
|
/// no recoverable partial data.
|
||||||
|
///
|
||||||
|
/// [rootBasePath] is the downloader's own resolved path for [BaseDirectory.root]
|
||||||
|
/// (`Task.baseDirectoryPath`). It is needed because the [Task] constructor strips one
|
||||||
|
/// leading separator from `directory`, so the stored value must be rejoined the same way
|
||||||
|
/// `Task.filePath` does before it can be compared with a real directory.
|
||||||
|
@visibleForTesting
|
||||||
|
bool isRelocatedRootTaskDirectory({
|
||||||
|
required Task task,
|
||||||
|
required String rootBasePath,
|
||||||
|
required String baseAppDirPath,
|
||||||
|
String? customRootPath,
|
||||||
|
}) {
|
||||||
|
// A SAF task also declares BaseDirectory.root, but its directory is a content:// tree
|
||||||
|
// URI owned by a document provider, not a path that moves with the app.
|
||||||
|
if (task is UriTask) return false;
|
||||||
|
if (task.baseDirectory != BaseDirectory.root) return false;
|
||||||
|
if (task.directory.isEmpty) return false;
|
||||||
|
final directory = path.join(rootBasePath, task.directory);
|
||||||
|
if (path.equals(directory, baseAppDirPath) || path.isWithin(baseAppDirPath, directory)) return false;
|
||||||
|
if (customRootPath != null && (path.equals(directory, customRootPath) || path.isWithin(customRootPath, directory))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
||||||
|
|
||||||
class _DownloadContext {
|
class _DownloadContext {
|
||||||
@@ -97,6 +142,7 @@ class DownloadManagerService {
|
|||||||
final Future<void> Function(MediaServerClient)? _queueProcessorOverride;
|
final Future<void> Function(MediaServerClient)? _queueProcessorOverride;
|
||||||
final Future<void> Function()? _nativeRecoveryOverride;
|
final Future<void> Function()? _nativeRecoveryOverride;
|
||||||
final Future<void> Function()? _fileDownloaderInitializerOverride;
|
final Future<void> Function()? _fileDownloaderInitializerOverride;
|
||||||
|
final NativeDownloaderOps? _nativeOpsOverride;
|
||||||
|
|
||||||
final DownloadLocationSnapshot Function()? _downloadLocationReader;
|
final DownloadLocationSnapshot Function()? _downloadLocationReader;
|
||||||
final Future<void> Function(String?)? _downloadPathWriter;
|
final Future<void> Function(String?)? _downloadPathWriter;
|
||||||
@@ -225,6 +271,7 @@ class DownloadManagerService {
|
|||||||
@visibleForTesting Future<void> Function(MediaServerClient)? queueProcessorOverride,
|
@visibleForTesting Future<void> Function(MediaServerClient)? queueProcessorOverride,
|
||||||
@visibleForTesting Future<void> Function()? fileDownloaderInitializerOverride,
|
@visibleForTesting Future<void> Function()? fileDownloaderInitializerOverride,
|
||||||
@visibleForTesting Future<void> Function()? nativeRecoveryOverride,
|
@visibleForTesting Future<void> Function()? nativeRecoveryOverride,
|
||||||
|
@visibleForTesting NativeDownloaderOps? nativeOpsOverride,
|
||||||
@visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader,
|
@visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader,
|
||||||
@visibleForTesting Future<void> Function(String?)? downloadPathWriter,
|
@visibleForTesting Future<void> Function(String?)? downloadPathWriter,
|
||||||
@visibleForTesting Future<void> Function(String?)? downloadPathTypeWriter,
|
@visibleForTesting Future<void> Function(String?)? downloadPathTypeWriter,
|
||||||
@@ -235,6 +282,7 @@ class DownloadManagerService {
|
|||||||
_nativeRecoveryOverride = nativeRecoveryOverride,
|
_nativeRecoveryOverride = nativeRecoveryOverride,
|
||||||
_database = database,
|
_database = database,
|
||||||
_fileDownloaderInitializerOverride = fileDownloaderInitializerOverride,
|
_fileDownloaderInitializerOverride = fileDownloaderInitializerOverride,
|
||||||
|
_nativeOpsOverride = nativeOpsOverride,
|
||||||
_storageService = storageService,
|
_storageService = storageService,
|
||||||
_clientResolver = clientResolver,
|
_clientResolver = clientResolver,
|
||||||
_http = http ?? httpClient,
|
_http = http ?? httpClient,
|
||||||
@@ -247,6 +295,17 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported;
|
bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported;
|
||||||
|
|
||||||
|
NativeDownloaderOps get _nativeOps =>
|
||||||
|
_nativeOpsOverride ??
|
||||||
|
(
|
||||||
|
allTasks: () => FileDownloader().allTasks(group: _downloadGroup),
|
||||||
|
allRecords: () => FileDownloader().database.allRecords(group: _downloadGroup),
|
||||||
|
deleteRecord: FileDownloader().database.deleteRecordWithId,
|
||||||
|
cancelTaskIds: FileDownloader().cancelTasksWithIds,
|
||||||
|
cleanUpOrphanedTempFiles: FileDownloader().cleanUpOrphanedTempFiles,
|
||||||
|
rescheduleKilledTasks: FileDownloader().rescheduleKilledTasks,
|
||||||
|
);
|
||||||
|
|
||||||
bool _skipDownloadsUnsupported(String operation) {
|
bool _skipDownloadsUnsupported(String operation) {
|
||||||
if (downloadsSupported) return false;
|
if (downloadsSupported) return false;
|
||||||
if (!_loggedDownloadsUnsupported) {
|
if (!_loggedDownloadsUnsupported) {
|
||||||
@@ -836,17 +895,23 @@ class DownloadManagerService {
|
|||||||
await nativeRecoveryOverride();
|
await nativeRecoveryOverride();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
|
// Strictly before the downloader is wired up. Initialization registers our status
|
||||||
await _initializeFileDownloader();
|
// callbacks and calls resumeFromBackground, which can deliver a failure a relocated
|
||||||
|
// task already hit on the old path — and a failed row is no longer restartable.
|
||||||
|
// rescheduleKilledTasks, further down, would re-enqueue it against that path again.
|
||||||
|
await _purgeRelocatedDownloadRecords();
|
||||||
|
|
||||||
final deletedTempFiles = await FileDownloader().cleanUpOrphanedTempFiles();
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
|
||||||
|
await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader());
|
||||||
|
|
||||||
|
final deletedTempFiles = await _nativeOps.cleanUpOrphanedTempFiles();
|
||||||
if (deletedTempFiles > 0) {
|
if (deletedTempFiles > 0) {
|
||||||
appLogger.i('Deleted $deletedTempFiles orphaned downloader temp file(s)');
|
appLogger.i('Deleted $deletedTempFiles orphaned downloader temp file(s)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let background_downloader re-enqueue tasks killed by the OS
|
// Let background_downloader re-enqueue tasks killed by the OS
|
||||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads')));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads')));
|
||||||
final (rescheduled, _) = await FileDownloader().rescheduleKilledTasks();
|
final (rescheduled, _) = await _nativeOps.rescheduleKilledTasks();
|
||||||
if (rescheduled.isNotEmpty) {
|
if (rescheduled.isNotEmpty) {
|
||||||
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
|
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
|
||||||
}
|
}
|
||||||
@@ -933,11 +998,13 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reconcileNativeDownloadTasks() async {
|
Future<void> _reconcileNativeDownloadTasks() async {
|
||||||
if (!downloadsSupported || !_fileDownloaderInitialized) return;
|
if (!downloadsSupported) return;
|
||||||
|
// An injected ops seam stands in for a wired-up downloader.
|
||||||
|
if (_nativeOpsOverride == null && !_fileDownloaderInitialized) return;
|
||||||
|
|
||||||
final List<Task> nativeTasks;
|
final List<Task> nativeTasks;
|
||||||
try {
|
try {
|
||||||
nativeTasks = await FileDownloader().allTasks(group: _downloadGroup);
|
nativeTasks = await _nativeOps.allTasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to enumerate native download tasks during recovery', error: e);
|
appLogger.w('Failed to enumerate native download tasks during recovery', error: e);
|
||||||
return;
|
return;
|
||||||
@@ -945,16 +1012,48 @@ class DownloadManagerService {
|
|||||||
if (nativeTasks.isEmpty) return;
|
if (nativeTasks.isEmpty) return;
|
||||||
|
|
||||||
final tasksByGlobalKey = <String, List<Task>>{};
|
final tasksByGlobalKey = <String, List<Task>>{};
|
||||||
|
final rootTasks = <Task>[];
|
||||||
for (final task in nativeTasks) {
|
for (final task in nativeTasks) {
|
||||||
|
if (task.baseDirectory == BaseDirectory.root) {
|
||||||
|
rootTasks.add(task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final globalKey = task.metaData;
|
final globalKey = task.metaData;
|
||||||
if (globalKey.isEmpty) continue;
|
if (globalKey.isEmpty) continue;
|
||||||
(tasksByGlobalKey[globalKey] ??= []).add(task);
|
(tasksByGlobalKey[globalKey] ??= []).add(task);
|
||||||
}
|
}
|
||||||
if (tasksByGlobalKey.isEmpty) return;
|
|
||||||
|
// A root-anchored task carries its absolute directory in the downloader's own
|
||||||
|
// persisted store, so one enqueued before the app's private storage moved resumes
|
||||||
|
// writing where the app no longer owns anything. Those cannot be resumed at all.
|
||||||
|
final relocatedTasksByGlobalKey = <String, List<Task>>{};
|
||||||
|
if (rootTasks.isNotEmpty) {
|
||||||
|
final rootBasePath = await Task.baseDirectoryPath(BaseDirectory.root);
|
||||||
|
final baseAppDirPath = await _storageService.baseAppDirectoryPath();
|
||||||
|
final customRootPath = _storageService.customFileRootPath;
|
||||||
|
for (final task in rootTasks) {
|
||||||
|
final relocated = isRelocatedRootTaskDirectory(
|
||||||
|
task: task,
|
||||||
|
rootBasePath: rootBasePath,
|
||||||
|
baseAppDirPath: baseAppDirPath,
|
||||||
|
customRootPath: customRootPath,
|
||||||
|
);
|
||||||
|
if (relocated) {
|
||||||
|
(relocatedTasksByGlobalKey[task.metaData] ??= []).add(task);
|
||||||
|
} else if (task.metaData.isNotEmpty) {
|
||||||
|
(tasksByGlobalKey[task.metaData] ??= []).add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (relocatedTasksByGlobalKey.isEmpty && tasksByGlobalKey.isEmpty) return;
|
||||||
|
|
||||||
final rows = await _database.select(_database.downloadedMedia).get();
|
final rows = await _database.select(_database.downloadedMedia).get();
|
||||||
final rowsByGlobalKey = {for (final row in rows) row.globalKey: row};
|
final rowsByGlobalKey = {for (final row in rows) row.globalKey: row};
|
||||||
|
|
||||||
|
for (final entry in relocatedTasksByGlobalKey.entries) {
|
||||||
|
await _discardRelocatedNativeTasks(entry.key, entry.value, rowsByGlobalKey[entry.key]);
|
||||||
|
}
|
||||||
|
|
||||||
for (final entry in tasksByGlobalKey.entries) {
|
for (final entry in tasksByGlobalKey.entries) {
|
||||||
final globalKey = entry.key;
|
final globalKey = entry.key;
|
||||||
final tasks = entry.value;
|
final tasks = entry.value;
|
||||||
@@ -993,6 +1092,98 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop downloader records whose target directory belongs to a previous location of the
|
||||||
|
/// app's private storage, and requeue the download so it restarts under the current one.
|
||||||
|
///
|
||||||
|
/// Runs before the downloader is initialized, and therefore before
|
||||||
|
/// [FileDownloader.rescheduleKilledTasks], for two reasons. Reschedule re-enqueues every
|
||||||
|
/// enqueued/running record it finds missing natively, carrying the relocated absolute
|
||||||
|
/// directory over verbatim. And initialization registers our status callbacks and calls
|
||||||
|
/// [FileDownloader.resumeFromBackground], which can deliver a failure such a task already
|
||||||
|
/// hit — marking the row failed, which [_requeueRelocatedDownload] deliberately will not
|
||||||
|
/// restart. Reading and deleting records needs no initialization: the record store is
|
||||||
|
/// Dart-side, as [discardInterruptedNativeDownloadsAfterStorageFailure] also relies on.
|
||||||
|
Future<void> _purgeRelocatedDownloadRecords() async {
|
||||||
|
if (!downloadsSupported) return;
|
||||||
|
|
||||||
|
final List<TaskRecord> records;
|
||||||
|
try {
|
||||||
|
records = await _nativeOps.allRecords();
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Failed to enumerate download records during recovery', error: e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final rootRecords = records.where((record) => record.task.baseDirectory == BaseDirectory.root).toList();
|
||||||
|
if (rootRecords.isEmpty) return;
|
||||||
|
|
||||||
|
final rootBasePath = await Task.baseDirectoryPath(BaseDirectory.root);
|
||||||
|
final baseAppDirPath = await _storageService.baseAppDirectoryPath();
|
||||||
|
final customRootPath = _storageService.customFileRootPath;
|
||||||
|
for (final record in rootRecords) {
|
||||||
|
if (!isRelocatedRootTaskDirectory(
|
||||||
|
task: record.task,
|
||||||
|
rootBasePath: rootBasePath,
|
||||||
|
baseAppDirPath: baseAppDirPath,
|
||||||
|
customRootPath: customRootPath,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final globalKey = record.task.metaData;
|
||||||
|
appLogger.i(
|
||||||
|
'Dropping download record ${record.taskId} for $globalKey targeting relocated '
|
||||||
|
'storage: ${record.task.directory}',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _nativeOps.deleteRecord(record.taskId);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Failed to drop relocated download record ${record.taskId}', error: e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await _cancelNativeTaskIds(globalKey, [record.taskId], reason: 'relocated app storage before rescheduling');
|
||||||
|
if (globalKey.isNotEmpty) await _requeueRelocatedDownload(globalKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel [tasks] that target storage the app no longer owns and put a restartable
|
||||||
|
/// download back in the queue so it downloads again under the current location.
|
||||||
|
Future<void> _discardRelocatedNativeTasks(String globalKey, List<Task> tasks, DownloadedMediaItem? row) async {
|
||||||
|
appLogger.i(
|
||||||
|
'Discarding ${tasks.length} download task(s) for $globalKey targeting relocated storage: '
|
||||||
|
'${tasks.map((task) => task.directory).toSet().join(', ')}',
|
||||||
|
);
|
||||||
|
await _cancelNativeTaskIds(
|
||||||
|
globalKey,
|
||||||
|
tasks.map((task) => task.taskId),
|
||||||
|
reason: 'relocated app storage during recovery',
|
||||||
|
);
|
||||||
|
if (row != null) await _requeueRelocatedDownload(row.globalKey, row: row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a download whose bytes are stranded on a previous storage location back to the
|
||||||
|
/// queue. A finished or already-abandoned row keeps its status: there is nothing to
|
||||||
|
/// restart, and its stored path is relative and therefore still valid.
|
||||||
|
Future<void> _requeueRelocatedDownload(String globalKey, {DownloadedMediaItem? row}) async {
|
||||||
|
final current = row ?? await _database.getDownloadedMedia(globalKey);
|
||||||
|
if (current == null) return;
|
||||||
|
|
||||||
|
final restartable = switch (DownloadStatus.values[current.status]) {
|
||||||
|
DownloadStatus.queued || DownloadStatus.downloading || DownloadStatus.paused => true,
|
||||||
|
DownloadStatus.completed || DownloadStatus.failed || DownloadStatus.cancelled || DownloadStatus.partial => false,
|
||||||
|
};
|
||||||
|
if (!restartable) return;
|
||||||
|
|
||||||
|
await _database.updateBgTaskId(globalKey, null);
|
||||||
|
await _database.updateDownloadStatus(globalKey, DownloadStatus.queued.index);
|
||||||
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugRecoverRelocatedDownloads() async {
|
||||||
|
await _purgeRelocatedDownloadRecords();
|
||||||
|
await _reconcileNativeDownloadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<void> debugReconcileSafGrantOwnership({List<Task> nativeTasks = const []}) {
|
Future<void> debugReconcileSafGrantOwnership({List<Task> nativeTasks = const []}) {
|
||||||
return _reconcileSafGrantOwnership(nativeTasks: nativeTasks);
|
return _reconcileSafGrantOwnership(nativeTasks: nativeTasks);
|
||||||
@@ -1005,7 +1196,7 @@ class DownloadManagerService {
|
|||||||
tasks = nativeTasks;
|
tasks = nativeTasks;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
tasks = await FileDownloader().allTasks(group: _downloadGroup);
|
tasks = await _nativeOps.allTasks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error);
|
appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error);
|
||||||
return;
|
return;
|
||||||
@@ -1148,7 +1339,7 @@ class DownloadManagerService {
|
|||||||
if (ids.isEmpty) return;
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final cancelled = await FileDownloader().cancelTasksWithIds(ids);
|
final cancelled = await _nativeOps.cancelTaskIds(ids);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
appLogger.d('Cancelled ${ids.length} native task(s) for $globalKey ($reason): ${ids.join(', ')}');
|
appLogger.d('Cancelled ${ids.length} native task(s) for $globalKey ($reason): ${ids.join(', ')}');
|
||||||
}
|
}
|
||||||
@@ -1834,11 +2025,13 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
await File(downloadFilePath).parent.create(recursive: true);
|
await File(downloadFilePath).parent.create(recursive: true);
|
||||||
|
|
||||||
|
final taskLocation = await _storageService.resolveTaskDirectory(downloadFilePath);
|
||||||
|
|
||||||
task = DownloadTask(
|
task = DownloadTask(
|
||||||
url: resolution.videoUrl!,
|
url: resolution.videoUrl!,
|
||||||
filename: path.basename(downloadFilePath),
|
filename: path.basename(downloadFilePath),
|
||||||
directory: path.dirname(downloadFilePath),
|
directory: taskLocation.directory,
|
||||||
baseDirectory: BaseDirectory.root,
|
baseDirectory: taskLocation.baseDirectory,
|
||||||
group: _downloadGroup,
|
group: _downloadGroup,
|
||||||
updates: Updates.statusAndProgress,
|
updates: Updates.statusAndProgress,
|
||||||
requiresWiFi: requiresWiFi,
|
requiresWiFi: requiresWiFi,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -79,15 +80,28 @@ class DownloadStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [_getBaseAppDir] resolves to the documents directory (mobile) or the
|
||||||
|
/// support directory (desktop). Single source of truth for that split, shared
|
||||||
|
/// with [resolveTaskDirectory] so the two can never disagree.
|
||||||
|
static bool get _baseAppDirIsDocuments => Platform.isAndroid || Platform.isIOS;
|
||||||
|
|
||||||
/// Get the base app directory for storing data.
|
/// Get the base app directory for storing data.
|
||||||
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
|
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
|
||||||
Future<Directory> _getBaseAppDir() {
|
Future<Directory> _getBaseAppDir() {
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (_baseAppDirIsDocuments) {
|
||||||
return getApplicationDocumentsDirectory();
|
return getApplicationDocumentsDirectory();
|
||||||
}
|
}
|
||||||
return getApplicationSupportDirectory();
|
return getApplicationSupportDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absolute path of the app-private directory that relative download paths are
|
||||||
|
/// anchored to. Moves with the app, so it must be read fresh rather than stored.
|
||||||
|
Future<String> baseAppDirectoryPath() async => (await _getBaseAppDir()).path;
|
||||||
|
|
||||||
|
/// Configured custom download root when it is a filesystem path, otherwise null.
|
||||||
|
/// A `saf` root is a `content://` tree URI instead — see [safBaseUri].
|
||||||
|
String? get customFileRootPath => _customPathType == 'file' ? _customDownloadPath : null;
|
||||||
|
|
||||||
/// Format episode filename base: S{XX}E{XX} - {Title}
|
/// Format episode filename base: S{XX}E{XX} - {Title}
|
||||||
String _formatEpisodeFileName(MediaItem episode) {
|
String _formatEpisodeFileName(MediaItem episode) {
|
||||||
final season = padNumber(episode.parentIndex ?? 0, 2);
|
final season = padNumber(episode.parentIndex ?? 0, 2);
|
||||||
@@ -348,18 +362,38 @@ class DownloadStorageService {
|
|||||||
// Strip the base directory prefix iteratively — background_downloader
|
// Strip the base directory prefix iteratively — background_downloader
|
||||||
// recovery paths can contain the base dir doubled (e.g.
|
// recovery paths can contain the base dir doubled (e.g.
|
||||||
// /data/.../app_flutter/data/.../app_flutter/downloads/...).
|
// /data/.../app_flutter/data/.../app_flutter/downloads/...).
|
||||||
|
//
|
||||||
|
// Containment, not a string prefix: a custom download root that merely starts with the
|
||||||
|
// base dir's name (`<base>-external`) is a sibling the app does not own, and stripping
|
||||||
|
// it would silently re-root the download inside app storage.
|
||||||
var result = absolutePath;
|
var result = absolutePath;
|
||||||
while (result.startsWith(baseDir.path)) {
|
while (path.isWithin(baseDir.path, result)) {
|
||||||
result = result.substring(baseDir.path.length);
|
result = path.relative(result, from: baseDir.path);
|
||||||
if (result.startsWith('/') || result.startsWith('\\')) {
|
|
||||||
result = result.substring(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (result != absolutePath) return result;
|
if (result != absolutePath) return result;
|
||||||
|
|
||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Base directory and directory to enqueue a download for [absolutePath] with.
|
||||||
|
///
|
||||||
|
/// A target inside the app's own storage is described relative to a base directory
|
||||||
|
/// that background_downloader re-resolves from the live app context on every launch,
|
||||||
|
/// so a task persisted across a restart survives the private data directory moving —
|
||||||
|
/// an iOS container UUID change, or an Android app moved to adoptable storage. Only a
|
||||||
|
/// custom download root, which lives outside that storage and therefore does not move
|
||||||
|
/// with the app, keeps [BaseDirectory.root] and its absolute path.
|
||||||
|
Future<({BaseDirectory baseDirectory, String directory})> resolveTaskDirectory(String absolutePath) async {
|
||||||
|
final relativePath = await toRelativePath(absolutePath);
|
||||||
|
if (relativePath == absolutePath) {
|
||||||
|
return (baseDirectory: BaseDirectory.root, directory: path.dirname(absolutePath));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
baseDirectory: _baseAppDirIsDocuments ? BaseDirectory.applicationDocuments : BaseDirectory.applicationSupport,
|
||||||
|
directory: path.dirname(relativePath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a relative file path to an absolute path (for file operations)
|
/// Convert a relative file path to an absolute path (for file operations)
|
||||||
/// Reconstructs the full path using the current app documents directory.
|
/// Reconstructs the full path using the current app documents directory.
|
||||||
Future<String> toAbsolutePath(String relativePath) async {
|
Future<String> toAbsolutePath(String relativePath) async {
|
||||||
|
|||||||
@@ -79,6 +79,255 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isRelocatedRootTaskDirectory', () {
|
||||||
|
const baseAppDir = '/mnt/expand/9f2a/user/0/com.edde746.plezy/app_flutter';
|
||||||
|
// Task strips one leading separator from `directory`, so a root-anchored task is
|
||||||
|
// rejoined against the downloader's own root base before it can be compared.
|
||||||
|
const rootBase = '/';
|
||||||
|
|
||||||
|
test('flags an absolute directory left behind by the previous app location', () {
|
||||||
|
final task = _rootTask('t', 'srv:item-1', '/data/user/0/com.edde746.plezy/app_flutter/downloads/srv/item-1');
|
||||||
|
|
||||||
|
expect(task.directory, 'data/user/0/com.edde746.plezy/app_flutter/downloads/srv/item-1');
|
||||||
|
expect(isRelocatedRootTaskDirectory(task: task, rootBasePath: rootBase, baseAppDirPath: baseAppDir), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a directory inside the live app storage', () {
|
||||||
|
final task = _rootTask('t', 'srv:item-1', '$baseAppDir/downloads/srv/item-1');
|
||||||
|
|
||||||
|
expect(isRelocatedRootTaskDirectory(task: task, rootBasePath: rootBase, baseAppDirPath: baseAppDir), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a directory inside the configured custom download root', () {
|
||||||
|
final task = _rootTask('t', 'srv:item-1', '/Volumes/External/Plezy/Movies/Arrival (2016)');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isRelocatedRootTaskDirectory(
|
||||||
|
task: task,
|
||||||
|
rootBasePath: rootBase,
|
||||||
|
baseAppDirPath: baseAppDir,
|
||||||
|
customRootPath: '/Volumes/External/Plezy',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a SAF task, whose root-anchored directory is a content tree URI', () {
|
||||||
|
final task = UriDownloadTask(
|
||||||
|
taskId: 't',
|
||||||
|
url: 'https://example.test/video.mp4',
|
||||||
|
filename: 'video.mp4',
|
||||||
|
directoryUri: Uri.parse('content://com.android.externalstorage.documents/tree/usb%3APlezy'),
|
||||||
|
metaData: 'srv:item-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.baseDirectory, BaseDirectory.root);
|
||||||
|
expect(isRelocatedRootTaskDirectory(task: task, rootBasePath: rootBase, baseAppDirPath: baseAppDir), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a task anchored to a base directory the downloader re-resolves itself', () {
|
||||||
|
final task = _downloadTask('t', 'srv:item-1');
|
||||||
|
|
||||||
|
expect(task.baseDirectory, isNot(BaseDirectory.root));
|
||||||
|
expect(isRelocatedRootTaskDirectory(task: task, rootBasePath: rootBase, baseAppDirPath: baseAppDir), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('relocated task recovery', () {
|
||||||
|
late Directory tmpRoot;
|
||||||
|
late PathProviderPlatform previousPathProvider;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
DownloadStorageService.resetForTesting();
|
||||||
|
tmpRoot = await Directory.systemTemp.createTemp('dms_relocated_');
|
||||||
|
previousPathProvider = PathProviderPlatform.instance;
|
||||||
|
PathProviderPlatform.instance = FakePathProvider(tmpRoot);
|
||||||
|
await DownloadStorageService.instance.initialize(await SettingsService.getInstance());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
DownloadStorageService.resetForTesting();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
PathProviderPlatform.instance = previousPathProvider;
|
||||||
|
if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
late List<String> cancelledIds;
|
||||||
|
late List<String> deletedRecordIds;
|
||||||
|
late List<String> callOrder;
|
||||||
|
|
||||||
|
Future<DownloadManagerService> managerFor(
|
||||||
|
AppDatabase db, {
|
||||||
|
List<Task> nativeTasks = const [],
|
||||||
|
List<TaskRecord> records = const [],
|
||||||
|
}) async {
|
||||||
|
cancelledIds = [];
|
||||||
|
deletedRecordIds = [];
|
||||||
|
callOrder = [];
|
||||||
|
final manager = DownloadManagerService(
|
||||||
|
database: db,
|
||||||
|
storageService: DownloadStorageService.instance,
|
||||||
|
clientResolver: (_, {clientScopeId}) => null,
|
||||||
|
downloadsSupportedOverride: true,
|
||||||
|
fileDownloaderInitializerOverride: () async => callOrder.add('initialize'),
|
||||||
|
nativeOpsOverride: (
|
||||||
|
allTasks: () async => nativeTasks,
|
||||||
|
allRecords: () async {
|
||||||
|
callOrder.add('allRecords');
|
||||||
|
return records;
|
||||||
|
},
|
||||||
|
deleteRecord: (taskId) async {
|
||||||
|
callOrder.add('deleteRecord:$taskId');
|
||||||
|
deletedRecordIds.add(taskId);
|
||||||
|
},
|
||||||
|
cancelTaskIds: (taskIds) async {
|
||||||
|
callOrder.add('cancel:${taskIds.join(",")}');
|
||||||
|
cancelledIds.addAll(taskIds);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
cleanUpOrphanedTempFiles: () async => 0,
|
||||||
|
rescheduleKilledTasks: () async {
|
||||||
|
callOrder.add('reschedule');
|
||||||
|
return (<Task>[], <Task>[]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(manager.dispose);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seedRow(AppDatabase db, DownloadStatus status, {String? taskId, String? videoFilePath}) async {
|
||||||
|
await db.insertDownload(
|
||||||
|
serverId: ServerId('srv'),
|
||||||
|
ratingKey: 'item-1',
|
||||||
|
globalKey: 'srv:item-1',
|
||||||
|
type: 'movie',
|
||||||
|
status: status.index,
|
||||||
|
);
|
||||||
|
if (taskId != null) await db.updateBgTaskId('srv:item-1', taskId);
|
||||||
|
if (videoFilePath != null) await db.updateVideoFilePath('srv:item-1', videoFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cancels a still-live task targeting the previous app location and requeues it', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
await seedRow(db, DownloadStatus.downloading, taskId: 'task-a');
|
||||||
|
final manager = await managerFor(
|
||||||
|
db,
|
||||||
|
nativeTasks: [_rootTask('task-a', 'srv:item-1', '$_previousAppDir/downloads/srv/item-1')],
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.debugRecoverRelocatedDownloads();
|
||||||
|
|
||||||
|
expect(cancelledIds, ['task-a']);
|
||||||
|
final row = await db.getDownloadedMedia('srv:item-1');
|
||||||
|
expect(row?.status, DownloadStatus.queued.index);
|
||||||
|
expect(row?.bgTaskId, isNull);
|
||||||
|
expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:item-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops a relocated record before rescheduling can re-enqueue it', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
await seedRow(db, DownloadStatus.downloading, taskId: 'task-a');
|
||||||
|
// A killed task survives only as a downloader record: rescheduleKilledTasks would
|
||||||
|
// re-enqueue it against the old volume, so it must be gone before that runs.
|
||||||
|
final manager = await managerFor(
|
||||||
|
db,
|
||||||
|
records: [
|
||||||
|
TaskRecord(
|
||||||
|
_rootTask('task-a', 'srv:item-1', '$_previousAppDir/downloads/srv/item-1'),
|
||||||
|
TaskStatus.enqueued,
|
||||||
|
0.4,
|
||||||
|
1024,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.debugRecoverRelocatedDownloads();
|
||||||
|
|
||||||
|
expect(deletedRecordIds, ['task-a']);
|
||||||
|
expect(cancelledIds, ['task-a']);
|
||||||
|
final row = await db.getDownloadedMedia('srv:item-1');
|
||||||
|
expect(row?.status, DownloadStatus.queued.index);
|
||||||
|
expect(row?.bgTaskId, isNull);
|
||||||
|
expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:item-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a record that still points inside the live app storage', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
await seedRow(db, DownloadStatus.downloading, taskId: 'task-a');
|
||||||
|
final liveDir = p.join(
|
||||||
|
await DownloadStorageService.instance.baseAppDirectoryPath(),
|
||||||
|
'downloads',
|
||||||
|
'srv',
|
||||||
|
'item-1',
|
||||||
|
);
|
||||||
|
final task = _rootTask('task-a', 'srv:item-1', liveDir);
|
||||||
|
final manager = await managerFor(
|
||||||
|
db,
|
||||||
|
nativeTasks: [task],
|
||||||
|
records: [TaskRecord(task, TaskStatus.enqueued, 0.4, 1024)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.debugRecoverRelocatedDownloads();
|
||||||
|
|
||||||
|
expect(deletedRecordIds, isEmpty);
|
||||||
|
expect(cancelledIds, isEmpty);
|
||||||
|
final row = await db.getDownloadedMedia('srv:item-1');
|
||||||
|
expect(row?.status, DownloadStatus.downloading.index);
|
||||||
|
expect(row?.bgTaskId, 'task-a');
|
||||||
|
expect(await db.getNextQueueItem(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancels a stale task for a finished download without disturbing the row', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
await seedRow(db, DownloadStatus.completed, videoFilePath: 'downloads/srv/item-1/video.mkv');
|
||||||
|
final manager = await managerFor(
|
||||||
|
db,
|
||||||
|
nativeTasks: [_rootTask('task-a', 'srv:item-1', '$_previousAppDir/downloads/srv/item-1')],
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.debugRecoverRelocatedDownloads();
|
||||||
|
|
||||||
|
expect(cancelledIds, ['task-a']);
|
||||||
|
final row = await db.getDownloadedMedia('srv:item-1');
|
||||||
|
expect(row?.status, DownloadStatus.completed.index);
|
||||||
|
expect(row?.videoFilePath, 'downloads/srv/item-1/video.mkv');
|
||||||
|
expect(await db.getNextQueueItem(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup drops relocated records before wiring up the downloader', () async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
await seedRow(db, DownloadStatus.downloading, taskId: 'task-a');
|
||||||
|
final manager = await managerFor(
|
||||||
|
db,
|
||||||
|
records: [
|
||||||
|
TaskRecord(
|
||||||
|
_rootTask('task-a', 'srv:item-1', '$_previousAppDir/downloads/srv/item-1'),
|
||||||
|
TaskStatus.enqueued,
|
||||||
|
0.4,
|
||||||
|
1024,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.recoverInterruptedDownloads();
|
||||||
|
|
||||||
|
// Initialization delivers statuses accumulated while suspended, which can mark the
|
||||||
|
// row failed and make it unrestartable; rescheduleKilledTasks then re-enqueues every
|
||||||
|
// enqueued record it finds missing natively, stale absolute directory and all. The
|
||||||
|
// record has to be gone before either.
|
||||||
|
expect(callOrder, ['allRecords', 'deleteRecord:task-a', 'cancel:task-a', 'initialize', 'reschedule']);
|
||||||
|
expect((await db.getNextQueueItem())?.mediaGlobalKey, 'srv:item-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('artworkStorageKey', () {
|
group('artworkStorageKey', () {
|
||||||
test('removes Jellyfin api_key from persisted artwork keys', () {
|
test('removes Jellyfin api_key from persisted artwork keys', () {
|
||||||
final url = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=secret-token';
|
final url = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=secret-token';
|
||||||
@@ -2530,6 +2779,20 @@ DownloadTask _downloadTask(String taskId, String globalKey) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the app's private storage lived before it was moved to another volume.
|
||||||
|
const _previousAppDir = '/data/user/0/com.edde746.plezy/app_flutter';
|
||||||
|
|
||||||
|
DownloadTask _rootTask(String taskId, String globalKey, String directory) {
|
||||||
|
return DownloadTask(
|
||||||
|
taskId: taskId,
|
||||||
|
url: 'https://example.test/video.mp4',
|
||||||
|
filename: 'video.mp4',
|
||||||
|
directory: directory,
|
||||||
|
baseDirectory: BaseDirectory.root,
|
||||||
|
metaData: globalKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
MediaItem _movie({String? thumbPath}) {
|
MediaItem _movie({String? thumbPath}) {
|
||||||
return testMediaItem(
|
return testMediaItem(
|
||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
|
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||||
@@ -271,13 +272,24 @@ void main() {
|
|||||||
final dss = DownloadStorageService.instance;
|
final dss = DownloadStorageService.instance;
|
||||||
await dss.initialize(settings);
|
await dss.initialize(settings);
|
||||||
|
|
||||||
// Content URIs and non-base absolute paths must round-trip untouched —
|
// Content URIs and non-base absolute paths must round-trip untouched — the
|
||||||
// the production code only strips paths that literally start with the
|
// production code only strips paths contained by the base dir.
|
||||||
// base dir.
|
|
||||||
const uri = '/Volumes/External/Movies/x.mkv';
|
const uri = '/Volumes/External/Movies/x.mkv';
|
||||||
expect(await dss.toRelativePath(uri), uri);
|
expect(await dss.toRelativePath(uri), uri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('leaves a sibling directory whose name merely starts with the base dir alone', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
final dss = DownloadStorageService.instance;
|
||||||
|
await dss.initialize(settings);
|
||||||
|
|
||||||
|
// `<base>-external` is a string prefix match but not inside the base dir. Stripping it
|
||||||
|
// would yield "-external/..." and silently re-root the file inside app storage.
|
||||||
|
final sibling = '${p.join(tmpRoot.path, 'support')}-external';
|
||||||
|
final outside = p.join(sibling, 'downloads', 'srv', '1', 'video.mkv');
|
||||||
|
expect(await dss.toRelativePath(outside), outside);
|
||||||
|
});
|
||||||
|
|
||||||
test('toAbsolutePath joins relative paths against the base dir', () async {
|
test('toAbsolutePath joins relative paths against the base dir', () async {
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final dss = DownloadStorageService.instance;
|
final dss = DownloadStorageService.instance;
|
||||||
@@ -310,6 +322,83 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('resolveTaskDirectory', () {
|
||||||
|
test('describes an app-storage target relative to the base directory', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
final dss = DownloadStorageService.instance;
|
||||||
|
await dss.initialize(settings);
|
||||||
|
|
||||||
|
final videoPath = await dss.getVideoFilePath(ServerId('srv'), 'item-1', 'mkv');
|
||||||
|
final location = await dss.resolveTaskDirectory(videoPath);
|
||||||
|
|
||||||
|
// Desktop hosts anchor downloads at the support directory; mobile uses documents.
|
||||||
|
expect(location.baseDirectory, BaseDirectory.applicationSupport);
|
||||||
|
expect(location.directory, p.join('downloads', 'srv', 'item-1'));
|
||||||
|
expect(p.isAbsolute(location.directory), isFalse);
|
||||||
|
expect(location.directory, isNot(contains(tmpRoot.path)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reanchors an enqueued target after the app storage directory moves', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
final dss = DownloadStorageService.instance;
|
||||||
|
await dss.initialize(settings);
|
||||||
|
|
||||||
|
final videoPath = await dss.getVideoFilePath(ServerId('srv'), 'item-1', 'mkv');
|
||||||
|
final location = await dss.resolveTaskDirectory(videoPath);
|
||||||
|
final storedTarget = p.join(location.directory, p.basename(videoPath));
|
||||||
|
expect(await dss.toAbsolutePath(storedTarget), videoPath);
|
||||||
|
|
||||||
|
// Stand in for the app being moved to another volume: the same base-directory
|
||||||
|
// lookup now resolves somewhere else, and the enqueued target must follow it.
|
||||||
|
final movedRoot = await Directory.systemTemp.createTemp('dss_moved_');
|
||||||
|
addTearDown(() async {
|
||||||
|
if (await movedRoot.exists()) await movedRoot.delete(recursive: true);
|
||||||
|
});
|
||||||
|
PathProviderPlatform.instance = FakePathProvider(movedRoot);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await dss.toAbsolutePath(storedTarget),
|
||||||
|
p.join(movedRoot.path, 'support', 'downloads', 'srv', 'item-1', 'video.mkv'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a custom download root absolute because it does not move with the app', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.customDownloadPathType, 'file');
|
||||||
|
final customRoot = p.join(tmpRoot.path, 'external', 'PlezyDownloads');
|
||||||
|
await settings.write(SettingsService.customDownloadPath, customRoot);
|
||||||
|
|
||||||
|
final dss = DownloadStorageService.instance;
|
||||||
|
await dss.initialize(settings);
|
||||||
|
|
||||||
|
final videoPath = await dss.getVideoFilePath(ServerId('srv'), 'item-1', 'mkv');
|
||||||
|
expect(videoPath, startsWith(customRoot));
|
||||||
|
|
||||||
|
final location = await dss.resolveTaskDirectory(videoPath);
|
||||||
|
expect(location.baseDirectory, BaseDirectory.root);
|
||||||
|
expect(location.directory, p.dirname(videoPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a custom root that only shares a name prefix with the app base dir', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.customDownloadPathType, 'file');
|
||||||
|
// Sibling of the base dir, not inside it: downloads must still land here, not be
|
||||||
|
// rewritten to "-external/..." underneath app storage.
|
||||||
|
final customRoot = '${p.join(tmpRoot.path, 'support')}-external';
|
||||||
|
await settings.write(SettingsService.customDownloadPath, customRoot);
|
||||||
|
|
||||||
|
final dss = DownloadStorageService.instance;
|
||||||
|
await dss.initialize(settings);
|
||||||
|
|
||||||
|
final videoPath = await dss.getVideoFilePath(ServerId('srv'), 'item-1', 'mkv');
|
||||||
|
expect(videoPath, startsWith(customRoot));
|
||||||
|
|
||||||
|
final location = await dss.resolveTaskDirectory(videoPath);
|
||||||
|
expect(location.baseDirectory, BaseDirectory.root);
|
||||||
|
expect(location.directory, p.dirname(videoPath));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ensureAbsolutePath / getReadablePath
|
// ensureAbsolutePath / getReadablePath
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user