mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 14:25:33 +02:00
fix(conversion): preserve Unicode SAF document names
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user