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
+204 -11
View File
@@ -43,6 +43,17 @@ typedef _NativeTaskForId = Future<Task?> Function(String taskId);
typedef _NativeResumeTask = Future<bool> Function(DownloadTask task);
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 DownloadLocationSnapshot = ({String? path, String? type});
@@ -61,6 +72,40 @@ NativeTaskPartition partitionNativeTasks(Iterable<Task> tasks, String? currentTa
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');
class _DownloadContext {
@@ -97,6 +142,7 @@ class DownloadManagerService {
final Future<void> Function(MediaServerClient)? _queueProcessorOverride;
final Future<void> Function()? _nativeRecoveryOverride;
final Future<void> Function()? _fileDownloaderInitializerOverride;
final NativeDownloaderOps? _nativeOpsOverride;
final DownloadLocationSnapshot Function()? _downloadLocationReader;
final Future<void> Function(String?)? _downloadPathWriter;
@@ -225,6 +271,7 @@ class DownloadManagerService {
@visibleForTesting Future<void> Function(MediaServerClient)? queueProcessorOverride,
@visibleForTesting Future<void> Function()? fileDownloaderInitializerOverride,
@visibleForTesting Future<void> Function()? nativeRecoveryOverride,
@visibleForTesting NativeDownloaderOps? nativeOpsOverride,
@visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader,
@visibleForTesting Future<void> Function(String?)? downloadPathWriter,
@visibleForTesting Future<void> Function(String?)? downloadPathTypeWriter,
@@ -235,6 +282,7 @@ class DownloadManagerService {
_nativeRecoveryOverride = nativeRecoveryOverride,
_database = database,
_fileDownloaderInitializerOverride = fileDownloaderInitializerOverride,
_nativeOpsOverride = nativeOpsOverride,
_storageService = storageService,
_clientResolver = clientResolver,
_http = http ?? httpClient,
@@ -247,6 +295,17 @@ class DownloadManagerService {
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) {
if (downloadsSupported) return false;
if (!_loggedDownloadsUnsupported) {
@@ -836,17 +895,23 @@ class DownloadManagerService {
await nativeRecoveryOverride();
return;
}
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
await _initializeFileDownloader();
// Strictly before the downloader is wired up. Initialization registers our status
// 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) {
appLogger.i('Deleted $deletedTempFiles orphaned downloader temp file(s)');
}
// Let background_downloader re-enqueue tasks killed by the OS
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads')));
final (rescheduled, _) = await FileDownloader().rescheduleKilledTasks();
final (rescheduled, _) = await _nativeOps.rescheduleKilledTasks();
if (rescheduled.isNotEmpty) {
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
}
@@ -933,11 +998,13 @@ class DownloadManagerService {
}
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;
try {
nativeTasks = await FileDownloader().allTasks(group: _downloadGroup);
nativeTasks = await _nativeOps.allTasks();
} catch (e) {
appLogger.w('Failed to enumerate native download tasks during recovery', error: e);
return;
@@ -945,16 +1012,48 @@ class DownloadManagerService {
if (nativeTasks.isEmpty) return;
final tasksByGlobalKey = <String, List<Task>>{};
final rootTasks = <Task>[];
for (final task in nativeTasks) {
if (task.baseDirectory == BaseDirectory.root) {
rootTasks.add(task);
continue;
}
final globalKey = task.metaData;
if (globalKey.isEmpty) continue;
(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 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) {
final globalKey = entry.key;
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
Future<void> debugReconcileSafGrantOwnership({List<Task> nativeTasks = const []}) {
return _reconcileSafGrantOwnership(nativeTasks: nativeTasks);
@@ -1005,7 +1196,7 @@ class DownloadManagerService {
tasks = nativeTasks;
} else {
try {
tasks = await FileDownloader().allTasks(group: _downloadGroup);
tasks = await _nativeOps.allTasks();
} catch (error) {
appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error);
return;
@@ -1148,7 +1339,7 @@ class DownloadManagerService {
if (ids.isEmpty) return;
try {
final cancelled = await FileDownloader().cancelTasksWithIds(ids);
final cancelled = await _nativeOps.cancelTaskIds(ids);
if (cancelled) {
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);
final taskLocation = await _storageService.resolveTaskDirectory(downloadFilePath);
task = DownloadTask(
url: resolution.videoUrl!,
filename: path.basename(downloadFilePath),
directory: path.dirname(downloadFilePath),
baseDirectory: BaseDirectory.root,
directory: taskLocation.directory,
baseDirectory: taskLocation.baseDirectory,
group: _downloadGroup,
updates: Updates.statusAndProgress,
requiresWiFi: requiresWiFi,
+40 -6
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import '../media/ids.dart';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.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.
/// Uses ApplicationDocumentsDirectory on mobile, ApplicationSupportDirectory on desktop.
Future<Directory> _getBaseAppDir() {
if (Platform.isAndroid || Platform.isIOS) {
if (_baseAppDirIsDocuments) {
return getApplicationDocumentsDirectory();
}
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}
String _formatEpisodeFileName(MediaItem episode) {
final season = padNumber(episode.parentIndex ?? 0, 2);
@@ -348,18 +362,38 @@ class DownloadStorageService {
// Strip the base directory prefix iteratively — background_downloader
// recovery paths can contain the base dir doubled (e.g.
// /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;
while (result.startsWith(baseDir.path)) {
result = result.substring(baseDir.path.length);
if (result.startsWith('/') || result.startsWith('\\')) {
result = result.substring(1);
}
while (path.isWithin(baseDir.path, result)) {
result = path.relative(result, from: baseDir.path);
}
if (result != absolutePath) return result;
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)
/// Reconstructs the full path using the current app documents directory.
Future<String> toAbsolutePath(String relativePath) async {