mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 13:29:09 +02:00
166 lines
4.8 KiB
Dart
166 lines
4.8 KiB
Dart
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;
|
|
}
|
|
|
|
try {
|
|
final uri = Uri.parse(pathOrUri);
|
|
final documentId = _safDocumentId(uri);
|
|
if (documentId == null || documentId.isEmpty) return pathOrUri;
|
|
|
|
final separatorIndex = documentId.indexOf(':');
|
|
if (separatorIndex <= 0) return pathOrUri;
|
|
|
|
final volumeId = documentId.substring(0, separatorIndex);
|
|
final relativePath = documentId
|
|
.substring(separatorIndex + 1)
|
|
.replaceAll('\\', '/');
|
|
|
|
if (volumeId.toLowerCase() == 'primary') {
|
|
return relativePath.isEmpty
|
|
? '/storage/emulated/0'
|
|
: '/storage/emulated/0/$relativePath';
|
|
}
|
|
|
|
// Media/document providers use opaque IDs such as audio:12345 or
|
|
// msf:100001. Presenting those as an SD-card path is misleading.
|
|
if (!_looksLikeStorageVolumeId(volumeId)) return pathOrUri;
|
|
return relativePath.isEmpty ? 'SD Card' : 'SD Card/$relativePath';
|
|
} catch (_) {
|
|
return pathOrUri;
|
|
}
|
|
}
|
|
|
|
String buildSafFileDisplayPath({
|
|
required String pathOrUri,
|
|
String? treeUri,
|
|
String? treeDisplayPath,
|
|
String? relativeDir,
|
|
String? fileName,
|
|
}) {
|
|
if (!pathOrUri.startsWith('content://')) return pathOrUri;
|
|
|
|
final displayRoot =
|
|
_friendlyDisplayRoot(treeDisplayPath) ??
|
|
_friendlyDisplayRoot(formatSafUriForDisplay(treeUri?.trim() ?? ''));
|
|
final cleanRelativeDir = _cleanDisplaySegment(relativeDir);
|
|
final cleanFileName = _cleanDisplaySegment(fileName);
|
|
|
|
if (displayRoot != null) {
|
|
return [
|
|
displayRoot.replaceAll(RegExp(r'/+$'), ''),
|
|
?cleanRelativeDir,
|
|
?cleanFileName,
|
|
].join('/');
|
|
}
|
|
|
|
return formatSafUriForDisplay(pathOrUri);
|
|
}
|
|
|
|
String? _safDocumentId(Uri uri) {
|
|
final segments = uri.pathSegments;
|
|
for (final marker in const ['document', 'tree']) {
|
|
final index = segments.indexOf(marker);
|
|
if (index != -1 && index + 1 < segments.length) {
|
|
final raw = segments[index + 1];
|
|
try {
|
|
return Uri.decodeComponent(raw);
|
|
} catch (_) {
|
|
return raw;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _looksLikeStorageVolumeId(String value) {
|
|
return RegExp(
|
|
r'^[0-9a-f]{4}-[0-9a-f]{4}$',
|
|
caseSensitive: false,
|
|
).hasMatch(value);
|
|
}
|
|
|
|
String? _friendlyDisplayRoot(String? value) {
|
|
final normalized = value?.trim().replaceAll('\\', '/');
|
|
if (normalized == null ||
|
|
normalized.isEmpty ||
|
|
normalized.startsWith('content://')) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
String? _cleanDisplaySegment(String? value) {
|
|
final normalized = value?.trim().replaceAll('\\', '/');
|
|
if (normalized == null || normalized.isEmpty) return null;
|
|
final withoutEdges = normalized.replaceAll(RegExp(r'^/+|/+$'), '');
|
|
return withoutEdges.isEmpty ? null : withoutEdges;
|
|
}
|