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,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