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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user