diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index b8613987..0b948887 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -940,36 +940,11 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { String relativeDir = ''; String oldFileName = ''; if (_isLocalItem) { - final uri = Uri.parse(cleanFilePath); - final pathSegments = uri.pathSegments; - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } + final location = resolveSafDocumentLocation(cleanFilePath); + if (location != null) { + treeUri = location.treeUri; + relativeDir = location.relativeDir; + oldFileName = location.fileName; } } else { treeUri = _downloadItem?.downloadTreeUri; diff --git a/lib/services/batch_track_actions.dart b/lib/services/batch_track_actions.dart index 31743d21..43f107fe 100644 --- a/lib/services/batch_track_actions.dart +++ b/lib/services/batch_track_actions.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/utils/saf_display_path.dart'; final _batchActionsLog = AppLogger('BatchActions'); @@ -408,48 +409,13 @@ Future _performBatchConversion( } } else if (isSaf && item.localItem != null) { failureStage = 'publish SAF output'; - final uri = Uri.parse(item.filePath); - final pathSegments = uri.pathSegments; + final location = resolveSafDocumentLocation(item.filePath); - String? treeUri; - String relativeDir = ''; - String oldFileName = ''; - - final treeIdx = pathSegments.indexOf('tree'); - final docIdx = pathSegments.indexOf('document'); - if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { - final treeId = pathSegments[treeIdx + 1]; - treeUri = - 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; - } - if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { - final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); - final slashIdx = docPath.lastIndexOf('/'); - if (slashIdx >= 0) { - oldFileName = docPath.substring(slashIdx + 1); - final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length - ? Uri.decodeFull(pathSegments[treeIdx + 1]) - : ''; - if (treeId.isNotEmpty && docPath.startsWith(treeId)) { - final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') - ? afterTree.substring(1) - : afterTree; - final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 - ? trimmed.substring(0, lastSlash) - : ''; - } - } else { - oldFileName = docPath; - } - } - - if (treeUri != null && oldFileName.isNotEmpty) { + if (location != null) { final published = await ConversionLibraryService.publishSafConversion( - treeUri: treeUri, - relativeDir: relativeDir, - originalFileName: oldFileName, + treeUri: location.treeUri, + relativeDir: location.relativeDir, + originalFileName: location.fileName, targetFormat: targetFormat, sourcePath: newPath, keepOriginal: keepOriginal, diff --git a/lib/utils/saf_display_path.dart b/lib/utils/saf_display_path.dart index 7399464e..57fe4460 100644 --- a/lib/utils/saf_display_path.dart +++ b/lib/utils/saf_display_path.dart @@ -1,3 +1,70 @@ +class SafDocumentLocation { + final String treeUri; + final String relativeDir; + final String fileName; + + const SafDocumentLocation({ + required this.treeUri, + required this.relativeDir, + required this.fileName, + }); +} + +/// Resolves the writable tree and relative destination of a SAF document URI. +/// +/// [Uri.pathSegments] already percent-decodes every segment. Decoding those +/// values again rejects ordinary Unicode filenames and corrupts literal `%` +/// characters, so all values below are used directly. +SafDocumentLocation? resolveSafDocumentLocation(String pathOrUri) { + try { + final uri = Uri.parse(pathOrUri.trim()); + if (uri.scheme != 'content' || uri.authority.isEmpty) return null; + + final segments = uri.pathSegments; + final treeIndex = segments.indexOf('tree'); + final documentIndex = segments.indexOf('document'); + if (treeIndex < 0 || + treeIndex + 1 >= segments.length || + documentIndex < 0 || + documentIndex + 1 >= segments.length) { + return null; + } + + final treeId = segments[treeIndex + 1]; + final documentId = segments[documentIndex + 1]; + if (treeId.isEmpty || documentId.isEmpty) return null; + + final lastSlash = documentId.lastIndexOf('/'); + final fileName = lastSlash >= 0 + ? documentId.substring(lastSlash + 1) + : documentId; + if (fileName.isEmpty) return null; + + var relativeDir = ''; + final belongsToTree = + documentId == treeId || documentId.startsWith('$treeId/'); + if (belongsToTree) { + var relativePath = documentId.substring(treeId.length); + if (relativePath.startsWith('/')) { + relativePath = relativePath.substring(1); + } + final relativeSlash = relativePath.lastIndexOf('/'); + if (relativeSlash >= 0) { + relativeDir = relativePath.substring(0, relativeSlash); + } + } + + return SafDocumentLocation( + treeUri: + '${uri.scheme}://${uri.authority}/tree/${Uri.encodeComponent(treeId)}', + relativeDir: relativeDir, + fileName: fileName, + ); + } catch (_) { + return null; + } +} + String formatSafUriForDisplay(String pathOrUri) { if (pathOrUri.isEmpty || !pathOrUri.startsWith('content://')) { return pathOrUri; diff --git a/test/saf_display_path_test.dart b/test/saf_display_path_test.dart index 65b5c2d3..c3a5c84b 100644 --- a/test/saf_display_path_test.dart +++ b/test/saf_display_path_test.dart @@ -2,6 +2,68 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/saf_display_path.dart'; void main() { + group('SAF document locations', () { + test('keeps Unicode and reserved filename characters decoded once', () { + final uri = Uri( + scheme: 'content', + host: 'com.android.externalstorage.documents', + pathSegments: const [ + 'tree', + 'primary:Music', + 'document', + 'primary:Music/Like a Prayer (From “Deadpool & Wolverine”).alac', + ], + ).toString(); + + final location = resolveSafDocumentLocation(uri); + + expect(location, isNotNull); + expect( + location!.treeUri, + 'content://com.android.externalstorage.documents/tree/primary%3AMusic', + ); + expect(location.relativeDir, isEmpty); + expect( + location.fileName, + 'Like a Prayer (From “Deadpool & Wolverine”).alac', + ); + }); + + test('preserves Unicode folders, emoji, and a literal percent sign', () { + final uri = Uri( + scheme: 'content', + host: 'com.android.externalstorage.documents', + pathSegments: const [ + 'tree', + 'primary:Music/Beyoncé', + 'document', + 'primary:Music/Beyoncé/日本語/100% & 🎵.flac', + ], + ).toString(); + + final location = resolveSafDocumentLocation(uri); + + expect(location, isNotNull); + expect( + location!.treeUri, + 'content://com.android.externalstorage.documents/tree/' + 'primary%3AMusic%2FBeyonc%C3%A9', + ); + expect(location.relativeDir, '日本語'); + expect(location.fileName, '100% & 🎵.flac'); + }); + + test('rejects a document URI without writable tree context', () { + expect( + resolveSafDocumentLocation( + 'content://com.android.providers.media.documents/' + 'document/audio%3A12345', + ), + isNull, + ); + }); + }); + group('SAF display paths', () { test('normalizes an external-storage document URI', () { const uri =