fix(conversion): preserve Unicode SAF document names

This commit is contained in:
zarzet
2026-09-04 12:39:03 +07:00
parent a0aa99f24d
commit 4aba2926f8
4 changed files with 140 additions and 70 deletions
+5 -30
View File
@@ -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;
+6 -40
View File
@@ -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<void> _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,
+67
View File
@@ -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;
+62
View File
@@ -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 =