Files
SpotiFLAC-Mobile/lib/utils/file_access.dart
zarzet 7ec5d28caf feat: add YouTube provider for lossy downloads via Cobalt API
- New YouTube download provider with Opus 256kbps and MP3 320kbps options
- SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during download
- Queue progress shows bytes (X.X MB) for streaming downloads
- Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC
- Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead
- Bumped version to 3.6.0
2026-02-09 18:15:43 +07:00

196 lines
5.7 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,
);
/// 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;
// 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;
}
// 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;
if (isValidIosWritablePath(path)) {
return path;
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.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',
);
}
// 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.',
);
}
// 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://');
}
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
if (isContentUri(path)) {
return PlatformBridge.safExists(path);
}
return File(path).exists();
}
Future<void> deleteFile(String? path) async {
if (path == null || path.isEmpty) 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;
if (isContentUri(path)) {
final stat = await PlatformBridge.safStat(path);
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(path);
if (stat.type == FileSystemEntityType.notFound) return null;
return FileAccessStat(size: stat.size, modified: stat.modified);
}
Future<void> openFile(String path) async {
if (isContentUri(path)) {
await PlatformBridge.openContentUri(path, mimeType: '');
return;
}
final mimeType = audioMimeTypeForPath(path);
final result = await OpenFilex.open(path, type: mimeType);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
}