mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 09:01:33 +02:00
Detect and recover from stale sandbox container paths embedded inside the current Documents directory. Extracts helper functions for path suffix normalization and joining to reduce duplication.
326 lines
9.9 KiB
Dart
326 lines
9.9 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/mime_utils.dart';
|
|
|
|
/// Regular expression to detect iOS app container paths.
|
|
/// Matches paths like /var/mobile/Containers/Data/Application/{UUID}
|
|
/// or /private/var/mobile/Containers/Data/Application/{UUID}
|
|
final _iosContainerRootPattern = RegExp(
|
|
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
|
|
caseSensitive: false,
|
|
);
|
|
final _iosContainerPathWithoutLeadingSlashPattern = RegExp(
|
|
r'^(private/)?var/mobile/Containers/Data/Application/[A-F0-9\-]+/.+',
|
|
caseSensitive: false,
|
|
);
|
|
final _iosLegacyRelativeDocumentsPattern = RegExp(
|
|
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
|
caseSensitive: false,
|
|
);
|
|
final _iosNestedLegacyDocumentsPattern = RegExp(
|
|
r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
String _normalizeRecoveredIosSuffix(String suffix) {
|
|
final trimmed = suffix.trim();
|
|
if (trimmed.isEmpty) return '';
|
|
return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed;
|
|
}
|
|
|
|
String _joinRecoveredIosPath(String documentsPath, String suffix) {
|
|
final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix);
|
|
if (normalizedSuffix.isEmpty) return documentsPath;
|
|
return '$documentsPath/$normalizedSuffix';
|
|
}
|
|
|
|
/// Checks if a path is a valid writable directory on iOS.
|
|
/// Returns false if:
|
|
/// - The path is the app container root (not writable)
|
|
/// - The path is an iCloud Drive path (not accessible by Go backend)
|
|
/// - The path is outside the app sandbox
|
|
bool isValidIosWritablePath(String path) {
|
|
if (!Platform.isIOS) return true;
|
|
if (path.isEmpty) return false;
|
|
if (!path.startsWith('/')) return false;
|
|
|
|
// Check if it's the container root (without Documents/, tmp/, etc.)
|
|
if (_iosContainerRootPattern.hasMatch(path)) {
|
|
return false;
|
|
}
|
|
|
|
// Check for iCloud Drive paths
|
|
if (path.contains('Mobile Documents') ||
|
|
path.contains('CloudDocs') ||
|
|
path.contains('com~apple~CloudDocs')) {
|
|
return false;
|
|
}
|
|
|
|
// Reject stale paths where an old sandbox container path has been embedded
|
|
// inside the current Documents directory.
|
|
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
|
|
return false;
|
|
}
|
|
|
|
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
|
|
// This handles cases where FilePicker returns container root
|
|
final containerPattern = RegExp(
|
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
|
caseSensitive: false,
|
|
);
|
|
final match = containerPattern.firstMatch(path);
|
|
if (match != null) {
|
|
final remainingPath = path.substring(match.end);
|
|
// Valid paths should have something after the UUID
|
|
if (remainingPath.isEmpty || remainingPath == '/') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Validates and potentially corrects an iOS path.
|
|
/// Returns a valid Documents subdirectory path if the input is invalid.
|
|
Future<String> validateOrFixIosPath(
|
|
String path, {
|
|
String subfolder = 'SpotiFLAC',
|
|
}) async {
|
|
if (!Platform.isIOS) return path;
|
|
|
|
final trimmed = path.trim();
|
|
final docDir = await getApplicationDocumentsDirectory();
|
|
|
|
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
|
|
trimmed,
|
|
);
|
|
if (nestedLegacyMatch != null) {
|
|
return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? '');
|
|
}
|
|
|
|
if (isValidIosWritablePath(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
final candidates = <String>[];
|
|
|
|
if (trimmed.isNotEmpty) {
|
|
candidates.add(trimmed);
|
|
}
|
|
|
|
// Some pickers can return absolute iOS paths without the leading slash.
|
|
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
|
|
candidates.add('/$trimmed');
|
|
}
|
|
|
|
// Recover legacy relative iOS path format:
|
|
// Data/Application/<UUID>/Documents/<subdir>
|
|
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
|
|
trimmed,
|
|
);
|
|
if (legacyRelativeMatch != null) {
|
|
candidates.add(
|
|
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
|
|
);
|
|
}
|
|
|
|
// Generic salvage for relative paths containing `Documents/...`.
|
|
if (!trimmed.startsWith('/')) {
|
|
final documentsMarker = 'Documents/';
|
|
final index = trimmed.indexOf(documentsMarker);
|
|
if (index >= 0) {
|
|
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
|
candidates.add(_joinRecoveredIosPath(docDir.path, suffix));
|
|
}
|
|
}
|
|
|
|
for (final candidate in candidates) {
|
|
if (isValidIosWritablePath(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
// Fall back to app Documents directory
|
|
final musicDir = Directory('${docDir.path}/$subfolder');
|
|
if (!await musicDir.exists()) {
|
|
await musicDir.create(recursive: true);
|
|
}
|
|
return musicDir.path;
|
|
}
|
|
|
|
/// Detailed result for iOS path validation
|
|
class IosPathValidationResult {
|
|
final bool isValid;
|
|
final String? correctedPath;
|
|
final String? errorReason;
|
|
|
|
const IosPathValidationResult({
|
|
required this.isValid,
|
|
this.correctedPath,
|
|
this.errorReason,
|
|
});
|
|
}
|
|
|
|
/// Validates an iOS path and returns detailed information about the result.
|
|
IosPathValidationResult validateIosPath(String path) {
|
|
if (!Platform.isIOS) {
|
|
return const IosPathValidationResult(isValid: true);
|
|
}
|
|
|
|
if (path.isEmpty) {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason: 'Path is empty',
|
|
);
|
|
}
|
|
|
|
if (!path.startsWith('/')) {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason:
|
|
'Invalid path format. Please choose a local folder from Files.',
|
|
);
|
|
}
|
|
|
|
// Check if it's the container root
|
|
if (_iosContainerRootPattern.hasMatch(path)) {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason:
|
|
'Cannot write to app container root. Please choose a subfolder like Documents.',
|
|
);
|
|
}
|
|
|
|
// Check for iCloud Drive paths
|
|
if (path.contains('Mobile Documents') ||
|
|
path.contains('CloudDocs') ||
|
|
path.contains('com~apple~CloudDocs')) {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason:
|
|
'iCloud Drive is not supported. Please choose a local folder.',
|
|
);
|
|
}
|
|
|
|
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason:
|
|
'Invalid iOS app folder path. Please choose App Documents or another local folder.',
|
|
);
|
|
}
|
|
|
|
// Check for container root without subdirectory
|
|
final containerPattern = RegExp(
|
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
|
caseSensitive: false,
|
|
);
|
|
final match = containerPattern.firstMatch(path);
|
|
if (match != null) {
|
|
final remainingPath = path.substring(match.end);
|
|
if (remainingPath.isEmpty || remainingPath == '/') {
|
|
return const IosPathValidationResult(
|
|
isValid: false,
|
|
errorReason:
|
|
'Cannot write to app container root. Please use the default folder or choose a different location.',
|
|
);
|
|
}
|
|
}
|
|
|
|
return const IosPathValidationResult(isValid: true);
|
|
}
|
|
|
|
class FileAccessStat {
|
|
final int? size;
|
|
final DateTime? modified;
|
|
|
|
const FileAccessStat({this.size, this.modified});
|
|
}
|
|
|
|
bool isContentUri(String? path) {
|
|
return path != null && path.startsWith('content://');
|
|
}
|
|
|
|
/// Pattern matching CUE virtual path suffixes like #track01, #track12, etc.
|
|
final _cueTrackSuffix = RegExp(r'#track\d+$');
|
|
|
|
const cueVirtualTrackRequiresSplitMessage =
|
|
'This CUE track is virtual. Use Split into Tracks first.';
|
|
|
|
/// Whether the path is a CUE virtual path (contains #trackNN suffix).
|
|
bool isCueVirtualPath(String? path) {
|
|
return path != null && _cueTrackSuffix.hasMatch(path);
|
|
}
|
|
|
|
/// Strip the #trackNN suffix from a CUE virtual path to get the base .cue path.
|
|
/// Returns the path unchanged if it's not a CUE virtual path.
|
|
String stripCueTrackSuffix(String path) {
|
|
return path.replaceFirst(_cueTrackSuffix, '');
|
|
}
|
|
|
|
Future<bool> fileExists(String? path) async {
|
|
if (path == null || path.isEmpty) return false;
|
|
// For CUE virtual paths, check if the base .cue file exists
|
|
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
|
|
if (isContentUri(realPath)) {
|
|
return PlatformBridge.safExists(realPath);
|
|
}
|
|
return File(realPath).exists();
|
|
}
|
|
|
|
Future<void> deleteFile(String? path) async {
|
|
if (path == null || path.isEmpty) return;
|
|
// CUE virtual paths should NOT be deleted through this function —
|
|
// deleting album.cue would remove ALL tracks. Callers should handle
|
|
// CUE deletion specially (e.g. only delete when all tracks are removed).
|
|
if (isCueVirtualPath(path)) return;
|
|
if (isContentUri(path)) {
|
|
await PlatformBridge.safDelete(path);
|
|
return;
|
|
}
|
|
try {
|
|
await File(path).delete();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<FileAccessStat?> fileStat(String? path) async {
|
|
if (path == null || path.isEmpty) return null;
|
|
// For CUE virtual paths, stat the base .cue file
|
|
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
|
|
if (isContentUri(realPath)) {
|
|
final stat = await PlatformBridge.safStat(realPath);
|
|
final exists = stat['exists'] as bool? ?? true;
|
|
if (!exists) return null;
|
|
return FileAccessStat(
|
|
size: stat['size'] as int?,
|
|
modified: stat['modified'] != null
|
|
? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
final stat = await FileStat.stat(realPath);
|
|
if (stat.type == FileSystemEntityType.notFound) return null;
|
|
return FileAccessStat(size: stat.size, modified: stat.modified);
|
|
}
|
|
|
|
Future<void> openFile(String path) async {
|
|
if (isCueVirtualPath(path)) {
|
|
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
|
}
|
|
|
|
final realPath = path;
|
|
if (isContentUri(realPath)) {
|
|
await PlatformBridge.openContentUri(realPath, mimeType: '');
|
|
return;
|
|
}
|
|
final mimeType = audioMimeTypeForPath(realPath);
|
|
final result = await OpenFilex.open(realPath, type: mimeType);
|
|
if (result.type != ResultType.done) {
|
|
throw Exception(result.message);
|
|
}
|
|
}
|