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:
edde746
2026-08-06 03:47:43 +02:00
parent 21cf1ff8d4
commit 309a107912
5 changed files with 606 additions and 21 deletions
@@ -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', () {
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';
@@ -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}) {
return testMediaItem(
id: 'item-1',