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
+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 {