fix(library): preserve retained files during duplicate cleanup

This commit is contained in:
zarzet
2026-08-17 21:03:02 +07:00
parent 2e115308b7
commit b378297a99
4 changed files with 185 additions and 17 deletions
@@ -1682,6 +1682,9 @@ object NativeDownloadFinalizer {
if (parsed.scheme.equals("file", ignoreCase = true)) {
parsed.path?.let { addNormalized(it) }
}
for (alias in androidExternalStorageDocumentPaths(parsed)) {
addNormalized(alias)
}
} else if (trimmed.startsWith("/")) {
try {
val asFileUri = Uri.fromFile(File(trimmed)).toString()
@@ -1708,6 +1711,40 @@ object NativeDownloadFinalizer {
return keys
}
private fun androidExternalStorageDocumentPaths(uri: Uri): List<String> {
if (
!uri.scheme.equals("content", ignoreCase = true) ||
!uri.authority.equals(
"com.android.externalstorage.documents",
ignoreCase = true,
)
) {
return emptyList()
}
val segments = uri.pathSegments
val documentIndex = segments.indexOfLast { it == "document" }
val treeIndex = segments.indexOfLast { it == "tree" }
val idIndex = if (documentIndex >= 0) documentIndex + 1 else treeIndex + 1
if (idIndex <= 0 || idIndex >= segments.size) return emptyList()
val documentId = segments.subList(idIndex, segments.size).joinToString("/")
val separator = documentId.indexOf(':')
if (
separator < 0 ||
!documentId.substring(0, separator).equals("primary", ignoreCase = true)
) {
return emptyList()
}
val relativePath = documentId
.substring(separator + 1)
.replace('\\', '/')
.trimStart('/')
val suffix = if (relativePath.isEmpty()) "" else "/$relativePath"
return androidStoragePathAliases.map { "$it$suffix" }
}
private fun stripUriQueryAndFragment(value: String): String {
val queryIndex = value.indexOf('?').let { if (it >= 0) it else value.length }
val fragmentIndex = value.indexOf('#').let { if (it >= 0) it else value.length }
+77 -10
View File
@@ -37,15 +37,46 @@ String? _stripAudioExtension(String path) {
return null;
}
Set<String> buildPathMatchKeys(String? filePath) {
/// Path aliases that refer to the same physical file.
///
/// Unlike [buildPathMatchKeys], this deliberately keeps the audio extension.
/// A converted `.flac` and `.opus` can coexist on disk and must not be treated
/// as the same physical file by destructive operations.
Set<String> buildPhysicalPathMatchKeys(String? filePath) =>
_buildPathMatchKeys(filePath, includeExtensionless: false);
Set<String> buildPathMatchKeys(String? filePath) =>
_buildPathMatchKeys(filePath, includeExtensionless: true);
bool physicalFilePathsMatch(String? first, String? second) {
final firstKeys = buildPhysicalPathMatchKeys(first);
if (firstKeys.isEmpty) return false;
return buildPhysicalPathMatchKeys(
second,
).any((key) => firstKeys.contains(key));
}
bool isPhysicalFileRetained(
String? candidatePath,
Iterable<String?> retainedPaths,
) => retainedPaths.any(
(retainedPath) => physicalFilePathsMatch(candidatePath, retainedPath),
);
Set<String> _buildPathMatchKeys(
String? filePath, {
required bool includeExtensionless,
}) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
if (cleaned.isEmpty) return const {};
final cached = _pathMatchKeyCache.remove(cleaned);
final cacheKey =
'${includeExtensionless ? 'track' : 'physical'}\u0000$cleaned';
final cached = _pathMatchKeyCache.remove(cacheKey);
if (cached != null) {
_pathMatchKeyCache[cleaned] = cached;
_pathMatchKeyCache[cacheKey] = cached;
return cached;
}
@@ -95,6 +126,10 @@ Set<String> buildPathMatchKeys(String? filePath) {
addNormalized(parsed.toFilePath());
} catch (_) {}
}
for (final alias in _androidExternalStorageDocumentPaths(parsed)) {
addNormalized(alias);
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
@@ -114,23 +149,55 @@ Set<String> buildPathMatchKeys(String? filePath) {
addNormalized(cleaned);
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
if (stripped != null && stripped.isNotEmpty) {
extensionStrippedKeys.add(stripped);
if (includeExtensionless) {
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
if (stripped != null && stripped.isNotEmpty) {
extensionStrippedKeys.add(stripped);
}
}
keys.addAll(extensionStrippedKeys);
}
keys.addAll(extensionStrippedKeys);
final result = Set<String>.unmodifiable(keys);
_pathMatchKeyCache[cleaned] = result;
_pathMatchKeyCache[cacheKey] = result;
while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) {
_pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first);
}
return result;
}
Iterable<String> _androidExternalStorageDocumentPaths(Uri uri) {
if (uri.scheme.toLowerCase() != 'content' ||
uri.host.toLowerCase() != 'com.android.externalstorage.documents') {
return const [];
}
final segments = uri.pathSegments;
final documentIndex = segments.lastIndexOf('document');
final treeIndex = segments.lastIndexOf('tree');
final idIndex = documentIndex >= 0 ? documentIndex + 1 : treeIndex + 1;
if (idIndex <= 0 || idIndex >= segments.length) return const [];
var documentId = segments.sublist(idIndex).join('/');
try {
documentId = Uri.decodeComponent(documentId);
} catch (_) {}
final separator = documentId.indexOf(':');
if (separator < 0 ||
documentId.substring(0, separator).toLowerCase() != 'primary') {
return const [];
}
final relativePath = documentId
.substring(separator + 1)
.replaceAll('\\', '/')
.replaceFirst(RegExp(r'^/+'), '');
final suffix = relativePath.isEmpty ? '' : '/$relativePath';
return _androidStoragePathAliases.map((prefix) => '$prefix$suffix');
}
Iterable<String> _androidEquivalentPaths(String path) {
final normalized = path.replaceAll('\\', '/');
final lower = normalized.toLowerCase();
+37 -7
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -109,13 +110,27 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
return confirmed == true && mounted;
}
Future<void> _deleteEntries(List<IsrcDuplicateEntry> entries) async {
Future<void> _deleteEntries(
List<IsrcDuplicateEntry> entries, {
required List<IsrcDuplicateEntry> retainedEntries,
}) async {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
var deleted = 0;
for (final entry in entries) {
final fileDeleted = await deleteFile(
DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath),
final entryPath = DownloadedEmbeddedCoverResolver.cleanFilePath(
entry.filePath,
);
final sharesRetainedFile = isPhysicalFileRetained(
entryPath,
retainedEntries.map(
(retained) =>
DownloadedEmbeddedCoverResolver.cleanFilePath(retained.filePath),
),
);
// Two database rows may describe one SAF file through different URI or
// raw-path aliases. Remove only the redundant row in that case; deleting
// the shared file would also destroy the copy the user chose to retain.
final fileDeleted = sharesRetainedFile || await deleteFile(entryPath);
if (!fileDeleted) continue;
if (entry.source == 'downloaded') {
historyNotifier.removeFromHistory(entry.id);
@@ -140,14 +155,29 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
group.entries.first.trackName,
),
);
if (confirmed) await _deleteEntries(toDelete);
if (confirmed) {
await _deleteEntries(toDelete, retainedEntries: [group.entries.first]);
}
}
Future<void> _deleteSingle(IsrcDuplicateEntry entry) async {
Future<void> _deleteSingle(
IsrcDuplicateGroup group,
IsrcDuplicateEntry entry,
) async {
final confirmed = await _confirmDelete(
context.l10n.duplicatesDeleteCopyMessage(entry.trackName),
);
if (confirmed) await _deleteEntries([entry]);
if (confirmed) {
await _deleteEntries(
[entry],
retainedEntries: group.entries
.where(
(candidate) =>
candidate.source != entry.source || candidate.id != entry.id,
)
.toList(growable: false),
);
}
}
@override
@@ -261,7 +291,7 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
: IconButton(
tooltip: context.l10n.dialogDelete,
icon: Icon(Icons.delete_outline, color: colorScheme.error),
onPressed: () => _deleteSingle(group.entries[i]),
onPressed: () => _deleteSingle(group, group.entries[i]),
),
),
],
+34
View File
@@ -1176,6 +1176,40 @@ void main() {
expect(keys, contains('c:/music/song.mp3'));
expect(keys, contains('C:/Music/Song'));
});
test('matches a primary-storage SAF URI to its raw Android path', () {
const safUri =
'content://com.android.externalstorage.documents/'
'tree/primary%3AMusic%2FFlac%20Songs/'
'document/primary%3AMusic%2FFlac%20Songs%2FAdore%20You.m4a';
const rawPath = '/storage/emulated/0/Music/Flac Songs/Adore You.m4a';
expect(physicalFilePathsMatch(safUri, rawPath), isTrue);
expect(buildPhysicalPathMatchKeys(safUri), contains(rawPath));
});
test('physical matching keeps coexisting audio formats distinct', () {
const flac = '/storage/emulated/0/Music/Song.flac';
const opus = '/storage/emulated/0/Music/Song.opus';
expect(
buildPathMatchKeys(flac),
contains('/storage/emulated/0/Music/Song'),
);
expect(physicalFilePathsMatch(flac, opus), isFalse);
expect(isPhysicalFileRetained(opus, [flac]), isFalse);
});
test('retains a shared SAF file during duplicate cleanup', () {
const safUri =
'content://com.android.externalstorage.documents/'
'document/primary%3AMusic%2FSong.flac';
expect(
isPhysicalFileRetained('/storage/emulated/0/Music/Song.flac', [safUri]),
isTrue,
);
});
});
group('AppRemoteConfig', () {