diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 0247b723..d5ff58ac 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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 { + 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 } diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index 4e35cda0..7cf33466 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -37,15 +37,46 @@ String? _stripAudioExtension(String path) { return null; } -Set 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 buildPhysicalPathMatchKeys(String? filePath) => + _buildPathMatchKeys(filePath, includeExtensionless: false); + +Set 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 retainedPaths, +) => retainedPaths.any( + (retainedPath) => physicalFilePathsMatch(candidatePath, retainedPath), +); + +Set _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 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 buildPathMatchKeys(String? filePath) { addNormalized(cleaned); - final extensionStrippedKeys = {}; - for (final key in keys) { - final stripped = _stripAudioExtension(key); - if (stripped != null && stripped.isNotEmpty) { - extensionStrippedKeys.add(stripped); + if (includeExtensionless) { + final extensionStrippedKeys = {}; + 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.unmodifiable(keys); - _pathMatchKeyCache[cleaned] = result; + _pathMatchKeyCache[cacheKey] = result; while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) { _pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first); } return result; } +Iterable _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 _androidEquivalentPaths(String path) { final normalized = path.replaceAll('\\', '/'); final lower = normalized.toLowerCase(); diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart index 4bd93943..e056b184 100644 --- a/lib/widgets/duplicate_review_sheet.dart +++ b/lib/widgets/duplicate_review_sheet.dart @@ -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 { return confirmed == true && mounted; } - Future _deleteEntries(List entries) async { + Future _deleteEntries( + List entries, { + required List 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 { group.entries.first.trackName, ), ); - if (confirmed) await _deleteEntries(toDelete); + if (confirmed) { + await _deleteEntries(toDelete, retainedEntries: [group.entries.first]); + } } - Future _deleteSingle(IsrcDuplicateEntry entry) async { + Future _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 { : IconButton( tooltip: context.l10n.dialogDelete, icon: Icon(Icons.delete_outline, color: colorScheme.error), - onPressed: () => _deleteSingle(group.entries[i]), + onPressed: () => _deleteSingle(group, group.entries[i]), ), ), ], diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 29bfb1e6..ecec1097 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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', () {