mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
This commit is contained in:
1 parent
1da0da18a6
commit
aa99726439
452 files changed
+64417
-65088
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.9.6';
|
||||
static const String buildNumber = '144';
|
||||
static const String version = '5.0.0';
|
||||
static const String buildNumber = '147';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static const String gitCommit = String.fromEnvironment('GIT_COMMIT');
|
||||
|
||||
@@ -4215,6 +4215,10 @@
|
||||
"@downloadLocationSubtitle": {
|
||||
"description": "Subtitle shown in the download location picker sheet"
|
||||
},
|
||||
"storageAutomaticFolder": "Automatic folder",
|
||||
"storageAutomaticDownloadFolder": "Uses Download/SpotiFLAC when writable, otherwise app storage",
|
||||
"storageSafRecommended": "Choose folder (Recommended)",
|
||||
"storageDownloadFolderHint": "In the Android folder picker, open Download and select SpotiFLAC. If it does not exist, create it using New folder, then tap Use this folder to grant access.",
|
||||
"storageModeAppFolder": "App Folder (Recommended)",
|
||||
"@storageModeAppFolder": {
|
||||
"description": "Storage mode option - app-managed folder"
|
||||
|
||||
@@ -5914,6 +5914,10 @@
|
||||
"libraryFilterTitle": "Filters",
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"extensionHooksAvailable": "{count} hook tersedia",
|
||||
"storageAutomaticFolder": "Folder otomatis",
|
||||
"storageAutomaticDownloadFolder": "Menggunakan Download/SpotiFLAC jika dapat ditulis, atau penyimpanan aplikasi",
|
||||
"storageSafRecommended": "Pilih folder (Disarankan)",
|
||||
"storageDownloadFolderHint": "Di pemilih folder Android, buka Download dan pilih SpotiFLAC. Jika belum ada, buat melalui Folder baru, lalu ketuk Gunakan folder ini untuk memberikan akses.",
|
||||
"backupSettingsOnly": "Pengaturan saja",
|
||||
"backupSelectHistory": "Riwayat download",
|
||||
"backupSelectCollections": "Koleksi library",
|
||||
|
||||
+94
-18
@@ -1,8 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
@@ -17,19 +21,23 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
|
||||
final _log = AppLogger('Main');
|
||||
_StartupBenchmark? _startupBenchmark;
|
||||
|
||||
void main() {
|
||||
if (const bool.fromEnvironment('CORE_BACKEND_STARTUP_BENCHMARK')) {
|
||||
_startupBenchmark = _StartupBenchmark();
|
||||
}
|
||||
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
|
||||
// Native (Go) crashes still can't be caught here.
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_startupBenchmark?.observeFirstFrame();
|
||||
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
@@ -66,7 +74,7 @@ void main() {
|
||||
),
|
||||
initialThemeSettingsProvider.overrideWithValue(bootstrapTheme),
|
||||
],
|
||||
child: _EagerInitialization(
|
||||
child: EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
),
|
||||
@@ -80,6 +88,79 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Opt-in release measurement. The ordinary startup path owns initialization;
|
||||
// reporting and validation happen after both measured boundaries are captured.
|
||||
class _StartupBenchmark {
|
||||
final _watch = Stopwatch()..start();
|
||||
late final Future<int> _firstFrame;
|
||||
|
||||
void observeFirstFrame() {
|
||||
_firstFrame = WidgetsBinding.instance.waitUntilFirstFrameRasterized.then(
|
||||
(_) => _watch.elapsedMicroseconds,
|
||||
);
|
||||
}
|
||||
|
||||
void extensionsReady(ExtensionState state, AppSettings settings) {
|
||||
final ready = _watch.elapsedMicroseconds;
|
||||
unawaited(_report(state, settings, ready));
|
||||
}
|
||||
|
||||
Future<void> _report(
|
||||
ExtensionState state,
|
||||
AppSettings settings,
|
||||
int ready,
|
||||
) async {
|
||||
try {
|
||||
if (!kReleaseMode) throw StateError('Startup benchmark requires release');
|
||||
final firstFrame = await _firstFrame.timeout(const Duration(seconds: 30));
|
||||
if (!state.isInitialized || state.error != null) {
|
||||
throw StateError('Extensions are not ready: ${state.error}');
|
||||
}
|
||||
final backends = await PlatformBridge.getBackendImplementations();
|
||||
const backend = String.fromEnvironment('EXPECTED_CORE_BACKEND');
|
||||
if (!const ['go', 'rust'].contains(backend) ||
|
||||
backends.length != 4 ||
|
||||
backends.values.any((value) => value != backend)) {
|
||||
throw StateError('Unexpected backend ownership: $backends');
|
||||
}
|
||||
await _write({
|
||||
'result': 'CORE_BACKEND_STARTUP_PASS backend=$backend',
|
||||
'startup': {
|
||||
'schema': 1,
|
||||
'backend': backend,
|
||||
'mode': 'release',
|
||||
'start_boundary': 'Dart main entry before bindings',
|
||||
'first_frame_boundary': 'first rasterized frame observed',
|
||||
'ready_boundary': 'production ensureInitialized completed',
|
||||
'cache': 'fresh process; warm filesystem',
|
||||
'first_frame_us': firstFrame,
|
||||
'extensions_ready_us': ready,
|
||||
'settings_sha256': sha256
|
||||
.convert(utf8.encode(jsonEncode(settings.toJson())))
|
||||
.toString(),
|
||||
'extensions': [
|
||||
for (final extension in state.extensions)
|
||||
{
|
||||
'id': extension.id,
|
||||
'enabled': extension.enabled,
|
||||
'status': extension.status,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await _write({'result': 'CORE_BACKEND_STARTUP_FAIL $error'});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _write(Map<String, Object?> result) async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
await File(
|
||||
'${support.path}/core-backend-probe-result.json',
|
||||
).writeAsString(jsonEncode({'pid': pid, ...result}), flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
const _runtimeProfileTierKey = 'runtime_profile_tier_v1';
|
||||
|
||||
Future<void> _prepareAndroidInstallationState(SharedPreferences prefs) async {
|
||||
@@ -220,16 +301,16 @@ class _RuntimeProfile {
|
||||
};
|
||||
}
|
||||
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
class EagerInitialization extends ConsumerStatefulWidget {
|
||||
const EagerInitialization({super.key, required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_EagerInitialization> createState() =>
|
||||
ConsumerState<EagerInitialization> createState() =>
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
class _EagerInitializationState extends ConsumerState<EagerInitialization>
|
||||
with WidgetsBindingObserver {
|
||||
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||
Timer? _downloadHistoryWarmupTimer;
|
||||
@@ -293,8 +374,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
ref.read(downloadQueueProvider.notifier).flushQueuePersistence(),
|
||||
);
|
||||
}
|
||||
// Backgrounded: return the Go heap's high-water mark to the OS so the
|
||||
// process is a smaller kill target.
|
||||
// Backgrounded: release idle native runtimes and connections.
|
||||
unawaited(PlatformBridge.releaseNativeMemory());
|
||||
}
|
||||
}
|
||||
@@ -302,7 +382,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
@override
|
||||
void didHaveMemoryPressure() {
|
||||
// OS memory pressure: drop decoded bitmaps (disk caches stay intact) and
|
||||
// have the Go side release freed heap back to the OS.
|
||||
// ask the native backend to release disposable runtimes and caches.
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
@@ -414,15 +494,11 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
|
||||
await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.initialize(
|
||||
storage.extensionsDir,
|
||||
storage.dataDir,
|
||||
masterKey: storage.masterKey,
|
||||
);
|
||||
await ref.read(extensionProvider.notifier).ensureInitialized();
|
||||
_startupBenchmark?.extensionsReady(
|
||||
ref.read(extensionProvider),
|
||||
ref.read(settingsProvider),
|
||||
);
|
||||
if (!mounted) return;
|
||||
NotificationService().verificationNotifications.setHandler((
|
||||
target,
|
||||
|
||||
@@ -197,6 +197,15 @@ class AppSettings {
|
||||
this.playerMode = 'external',
|
||||
});
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
Map<String, dynamic> get lyricsFetchOptions => {
|
||||
'include_translation_netease': lyricsIncludeTranslationNetease,
|
||||
'include_romanization_netease': lyricsIncludeRomanizationNetease,
|
||||
'multi_person_word_by_word': lyricsMultiPersonWordByWord,
|
||||
'apple_elrc_word_sync': lyricsAppleElrcWordSync,
|
||||
'musixmatch_language': musixmatchLanguage,
|
||||
};
|
||||
|
||||
AppSettings copyWith({
|
||||
String? defaultService,
|
||||
String? audioQuality,
|
||||
|
||||
@@ -295,7 +295,7 @@ final _multiUnderscoreRegex = RegExp(r'_+');
|
||||
double _log10(num x) => log(x) / ln10;
|
||||
final _yearRegex = RegExp(r'^(\d{4})');
|
||||
const _defaultOutputFolderName = 'SpotiFLAC';
|
||||
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
||||
const _defaultAndroidDownloadSubpath = 'Download/$_defaultOutputFolderName';
|
||||
const _maxSafFilenameUtf8Bytes = 180;
|
||||
const _maxSafDirSegmentUtf8Bytes = 120;
|
||||
final _batchUniqueFilenameTokenPattern = RegExp(
|
||||
@@ -554,7 +554,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
_iosBackgroundExecutionExpired = true;
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
pauseQueue(persistAcrossRestarts: false);
|
||||
pauseQueue(
|
||||
persistAcrossRestarts: false,
|
||||
nativeCancelledItemIds: cancelledItemIds,
|
||||
);
|
||||
}
|
||||
|
||||
if (requeueItemIds.isNotEmpty) {
|
||||
@@ -1275,9 +1278,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_pausePendingItemIds.clear();
|
||||
}
|
||||
|
||||
void pauseQueue({bool persistAcrossRestarts = true}) {
|
||||
void pauseQueue({
|
||||
bool persistAcrossRestarts = true,
|
||||
Set<String> nativeCancelledItemIds = const {},
|
||||
}) {
|
||||
if (state.isProcessing && !state.isPaused) {
|
||||
if (_hasActiveAndroidNativeWorker) {
|
||||
final nativeWorkerActive = _hasActiveAndroidNativeWorker;
|
||||
if (nativeWorkerActive) {
|
||||
PlatformBridge.pauseNativeDownloadWorker().catchError((_) {});
|
||||
}
|
||||
final activeIds = state.items
|
||||
@@ -1292,7 +1299,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (activeIds.isNotEmpty) {
|
||||
_pausePendingItemIds.addAll(activeIds);
|
||||
for (final id in activeIds) {
|
||||
_requestNativeCancel(id);
|
||||
if (nativeWorkerActive || nativeCancelledItemIds.contains(id)) {
|
||||
// The native worker or iOS expiry already cancelled this attempt.
|
||||
// A second cancel can arrive after it unwinds and leave a flag
|
||||
// that aborts the resumed download before it starts.
|
||||
_verificationWaitCoordinator.cancelItem(id);
|
||||
} else {
|
||||
_requestNativeCancel(id);
|
||||
}
|
||||
_requeueItemForPause(id);
|
||||
}
|
||||
}
|
||||
@@ -1343,7 +1357,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||
// A cancel issued while the item never started leaves a pre-registered
|
||||
// flag in the Go backend that the next attempt would consume and abort
|
||||
// flag in the backend that the next attempt would consume and abort
|
||||
// instantly; the user asked for a retry, so drop it first.
|
||||
try {
|
||||
await PlatformBridge.resetDownloadCancel(id);
|
||||
@@ -1776,7 +1790,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
await _initOutputDir();
|
||||
}
|
||||
|
||||
// iOS: Validate that outputDir is writable (not iCloud Drive which Go
|
||||
// iOS: Validate that outputDir is writable (not iCloud Drive which native code
|
||||
// can't access), unless a bookmark makes this app-Documents path-shape
|
||||
// check irrelevant (see shouldValidateIosOutputDir).
|
||||
if (shouldValidateIosOutputDir(
|
||||
@@ -1793,7 +1807,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w(
|
||||
'iOS: iCloud Drive path detected, falling back to app Documents folder',
|
||||
);
|
||||
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
|
||||
_log.w('Native backend cannot access this iCloud Drive path');
|
||||
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
||||
|
||||
@@ -833,17 +833,17 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
final coverPath = '${tempDir.path}/cover_embed_$uniqueId.jpg';
|
||||
|
||||
// Go's cover pipeline: shared cache/singleflight, retries, timeouts.
|
||||
// Mobile backends allocate temporary media inside their owned scope.
|
||||
final result = await PlatformBridge.downloadCoverToFile(
|
||||
coverUrl,
|
||||
coverPath,
|
||||
Platform.isAndroid || Platform.isIOS ? '' : coverPath,
|
||||
maxDimension: maxDimension,
|
||||
);
|
||||
if (result['error'] != null) {
|
||||
_log.w('Failed to download cover: ${result['error']}');
|
||||
return null;
|
||||
}
|
||||
return coverPath;
|
||||
return result['file_path'] as String? ?? coverPath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to download cover for embedding: $e');
|
||||
return null;
|
||||
|
||||
@@ -1382,6 +1382,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
final postProcessedPath = await _runPostProcessingHooks(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
item.id,
|
||||
);
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
filePath = postProcessedPath;
|
||||
|
||||
@@ -6,8 +6,8 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
return Directory('$documentsPath/$_defaultOutputFolderName');
|
||||
}
|
||||
|
||||
Directory _defaultAndroidMusicOutputDir(String storageRootPath) {
|
||||
return Directory('$storageRootPath/$_defaultAndroidMusicSubpath');
|
||||
Directory _defaultAndroidDownloadOutputDir(String storageRootPath) {
|
||||
return Directory('$storageRootPath/$_defaultAndroidDownloadSubpath');
|
||||
}
|
||||
|
||||
Future<Directory> _ensureDefaultDocumentsOutputDir() async {
|
||||
@@ -19,17 +19,17 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
return musicDir;
|
||||
}
|
||||
|
||||
Future<Directory?> _ensureDefaultAndroidMusicOutputDir() async {
|
||||
Future<Directory?> _ensureDefaultAndroidDownloadOutputDir() async {
|
||||
final dir = await getExternalStorageDirectory();
|
||||
if (dir == null) return null;
|
||||
|
||||
final musicDir = _defaultAndroidMusicOutputDir(
|
||||
final downloadDir = _defaultAndroidDownloadOutputDir(
|
||||
dir.parent.parent.parent.parent.path,
|
||||
);
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
if (!await downloadDir.exists()) {
|
||||
await downloadDir.create(recursive: true);
|
||||
}
|
||||
return musicDir;
|
||||
return downloadDir;
|
||||
}
|
||||
|
||||
Future<Directory?> _ensureAndroidAppSpecificOutputDir() async {
|
||||
@@ -78,7 +78,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
|
||||
Future<Directory> _findWritableAppFolder({String? failedOutputDir}) async {
|
||||
final candidates = <Future<Directory?> Function()>[
|
||||
if (Platform.isAndroid) _ensureDefaultAndroidMusicOutputDir,
|
||||
if (Platform.isAndroid) _ensureDefaultAndroidDownloadOutputDir,
|
||||
if (Platform.isAndroid) _ensureAndroidAppSpecificOutputDir,
|
||||
() async => _ensureDefaultDocumentsOutputDir(),
|
||||
];
|
||||
@@ -141,11 +141,10 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
_appFolderStorageFallback = fallback;
|
||||
try {
|
||||
return await fallback;
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (identical(_appFolderStorageFallback, fallback)) {
|
||||
_appFolderStorageFallback = null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +156,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
} else {
|
||||
final musicDir =
|
||||
await _ensureDefaultAndroidMusicOutputDir() ??
|
||||
await _ensureDefaultAndroidDownloadOutputDir() ??
|
||||
await _ensureDefaultDocumentsOutputDir();
|
||||
state = state.copyWith(outputDir: musicDir.path);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ extension _SingleItemDownload on DownloadQueueNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _runPostProcessingHooks(String filePath, Track track) async {
|
||||
Future<String?> _runPostProcessingHooks(
|
||||
String filePath,
|
||||
Track track,
|
||||
String itemId,
|
||||
) async {
|
||||
try {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
@@ -48,6 +52,7 @@ extension _SingleItemDownload on DownloadQueueNotifier {
|
||||
final result = await PlatformBridge.runPostProcessingV2(
|
||||
filePath,
|
||||
metadata: metadata,
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
@@ -126,6 +131,7 @@ class _DownloadRun {
|
||||
/// probe doesn't have to copy the published file back out of SAF.
|
||||
Map<String, dynamic>? probedFinalMetadata;
|
||||
bool externalLrcWritten = false;
|
||||
final Map<String, String> _directoryScopes = {};
|
||||
|
||||
Future<void> _run() async {
|
||||
final normalizedService = n._normalizeQueuedService(item.service);
|
||||
@@ -258,6 +264,14 @@ class _DownloadRun {
|
||||
} catch (e, stackTrace) {
|
||||
await _handleRunException(e, stackTrace);
|
||||
} finally {
|
||||
for (final token in _directoryScopes.values) {
|
||||
try {
|
||||
await PlatformBridge.releaseDownloadDirectory(token);
|
||||
} catch (e) {
|
||||
_log.w('Failed to release output directory scope: $e');
|
||||
}
|
||||
}
|
||||
_directoryScopes.clear();
|
||||
if (pausedDuringThisRun) {
|
||||
n._pausePendingItemIds.remove(item.id);
|
||||
}
|
||||
@@ -564,6 +578,10 @@ class _DownloadRun {
|
||||
|
||||
if (!useSaf) {
|
||||
await n._ensureDirExists(outputDir, label: 'Output folder');
|
||||
if (!_directoryScopes.containsKey(outputDir)) {
|
||||
_directoryScopes[outputDir] =
|
||||
await PlatformBridge.acquireDownloadDirectory(outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Output dir: $outputDir');
|
||||
@@ -740,11 +758,28 @@ class _DownloadRun {
|
||||
final postProcessedPath = await n._runPostProcessingHooks(
|
||||
hookInput,
|
||||
trackToDownload,
|
||||
item.id,
|
||||
);
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
if (deferredSafPublish && postProcessedPath != hookInput) {
|
||||
final output = File(postProcessedPath);
|
||||
if (!await output.exists() || await output.length() <= 0) {
|
||||
throw StateError('Post-processing output missing or empty');
|
||||
}
|
||||
await deleteFile(hookInput);
|
||||
}
|
||||
filePath = postProcessedPath;
|
||||
result['file_path'] = postProcessedPath;
|
||||
}
|
||||
if (await _shouldAbort(
|
||||
'during post-processing',
|
||||
deleteFileOnAbort: wasExisting ? null : filePath,
|
||||
)) {
|
||||
if (!wasExisting && filePath != hookInput) {
|
||||
await deleteFile(hookInput);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final autoConvertInput = filePath;
|
||||
@@ -800,7 +835,8 @@ class _DownloadRun {
|
||||
}
|
||||
|
||||
final lrcTarget = filePath;
|
||||
if (effectiveSafMode && lrcTarget != null && isContentUri(lrcTarget)) {
|
||||
if (lrcTarget != null &&
|
||||
(!wasExisting || (effectiveSafMode && isContentUri(lrcTarget)))) {
|
||||
externalLrcWritten = await n._saveExternalLrc(
|
||||
result: result,
|
||||
settings: settings,
|
||||
@@ -808,7 +844,7 @@ class _DownloadRun {
|
||||
track: trackToDownload,
|
||||
service: item.service,
|
||||
filePath: lrcTarget,
|
||||
storageMode: 'saf',
|
||||
storageMode: effectiveSafMode ? 'saf' : 'app',
|
||||
downloadTreeUri: settings.downloadTreeUri,
|
||||
safRelativeDir: effectiveOutputDir,
|
||||
resolveBaseName: () async {
|
||||
@@ -958,9 +994,8 @@ class _DownloadRun {
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
!isContentUriPath &&
|
||||
isFlacFile &&
|
||||
!wasExisting &&
|
||||
decryptionDescriptor != null) {
|
||||
await _embedLocalFlacAfterDecrypt(path);
|
||||
!wasExisting) {
|
||||
await _embedLocalFlac(path);
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
!isContentUriPath &&
|
||||
effectiveSafMode &&
|
||||
@@ -1454,14 +1489,16 @@ class _DownloadRun {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _embedLocalFlacAfterDecrypt(String currentFilePath) async {
|
||||
_log.d(
|
||||
'Local FLAC after decrypt detected, embedding metadata and cover...',
|
||||
);
|
||||
Future<void> _embedLocalFlac(String currentFilePath) async {
|
||||
_log.d('Local FLAC detected, embedding metadata and cover...');
|
||||
try {
|
||||
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.99);
|
||||
|
||||
await _embedFinalMetadata(currentFilePath, format: 'flac');
|
||||
await _embedFinalMetadata(
|
||||
currentFilePath,
|
||||
format: 'flac',
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
_log.d('Local FLAC metadata embedding completed');
|
||||
} catch (e) {
|
||||
_log.w('Local FLAC metadata embedding failed: $e');
|
||||
@@ -1473,7 +1510,7 @@ class _DownloadRun {
|
||||
Future<String?> _embedFinalMetadata(
|
||||
String path, {
|
||||
required String format,
|
||||
bool writeExternalLrc = true,
|
||||
bool writeExternalLrc = false,
|
||||
}) async {
|
||||
final track = buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/extension_storage_service.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
@@ -26,12 +28,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
Completer<void>? _initializationCompleter;
|
||||
Future<void>? _ensureInitializationFuture;
|
||||
Future<void> _downloadDirectorySync = Future<void>.value();
|
||||
final Map<String, DateTime> _healthExpiresAt = {};
|
||||
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
|
||||
final Map<String, int> _healthRequestSerial = {};
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
ref.listen<AppSettings>(settingsProvider, (previous, next) {
|
||||
if (state.isInitialized &&
|
||||
(previous?.downloadDirectory != next.downloadDirectory ||
|
||||
previous?.downloadDirectoryBookmark !=
|
||||
next.downloadDirectoryBookmark ||
|
||||
previous?.storageMode != next.storageMode)) {
|
||||
unawaited(_syncDownloadDirectory(next));
|
||||
}
|
||||
});
|
||||
_appLifecycleListener ??= AppLifecycleListener(
|
||||
onDetach: _scheduleLifecycleCleanup,
|
||||
);
|
||||
@@ -45,6 +58,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return const ExtensionState();
|
||||
}
|
||||
|
||||
Future<void> _syncDownloadDirectory(AppSettings settings) {
|
||||
_downloadDirectorySync = _downloadDirectorySync
|
||||
.then((_) async {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
final candidate = settings.downloadDirectory.trim();
|
||||
final path =
|
||||
settings.storageMode == 'app' &&
|
||||
settings.downloadDirectoryBookmark.isEmpty &&
|
||||
candidate.startsWith('/') &&
|
||||
await Directory(candidate).exists()
|
||||
? candidate
|
||||
: '';
|
||||
try {
|
||||
await PlatformBridge.setDownloadDirectory(path);
|
||||
} catch (_) {
|
||||
// An invalid new selection must not leave the old permanent grant.
|
||||
await PlatformBridge.setDownloadDirectory('');
|
||||
rethrow;
|
||||
}
|
||||
})
|
||||
.catchError((Object error) {
|
||||
_log.w('Failed to sync output directory access: $error');
|
||||
});
|
||||
return _downloadDirectorySync;
|
||||
}
|
||||
|
||||
void _scheduleLifecycleCleanup() {
|
||||
if (_cleanupInFlight) return;
|
||||
_cleanupInFlight = true;
|
||||
@@ -67,6 +106,34 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup and install/update actions share initialization, including the
|
||||
/// private storage preparation that happens before the native call.
|
||||
Future<void> ensureInitialized() {
|
||||
if (state.isInitialized) return Future<void>.value();
|
||||
return _ensureInitializationFuture ??= _initializeFromStorage()
|
||||
.whenComplete(() {
|
||||
_ensureInitializationFuture = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initializeFromStorage() async {
|
||||
final pending = _initializationCompleter?.future;
|
||||
if (pending != null) {
|
||||
await pending;
|
||||
} else {
|
||||
await ref.read(settingsProvider.notifier).ensureLoaded();
|
||||
final storage = await ExtensionStorageService.prepare();
|
||||
await initialize(
|
||||
storage.extensionsDir,
|
||||
storage.dataDir,
|
||||
masterKey: storage.masterKey,
|
||||
);
|
||||
}
|
||||
if (!state.isInitialized) {
|
||||
throw StateError(state.error ?? 'Extension system initialization failed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize(
|
||||
String extensionsDir,
|
||||
String dataDir, {
|
||||
@@ -97,6 +164,21 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
try {
|
||||
final settings = ref.read(settingsProvider.notifier);
|
||||
await settings.ensureLoaded();
|
||||
final snapshot = ref.read(settingsProvider);
|
||||
final outputDirectory = snapshot.downloadDirectory.trim();
|
||||
final allowedDirectories = <String>[];
|
||||
// External scopes are acquired by the file/download operation. Missing
|
||||
// output folders are resolved by the queue and must not prevent startup.
|
||||
if (snapshot.storageMode == 'app' &&
|
||||
snapshot.downloadDirectoryBookmark.isEmpty &&
|
||||
outputDirectory.startsWith('/')) {
|
||||
final directory = Directory(outputDirectory);
|
||||
if (await directory.exists()) {
|
||||
allowedDirectories.add(directory.path);
|
||||
}
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await PlatformBridge.prepareRuntimeState(dataDir);
|
||||
@@ -108,11 +190,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
extensionsDir,
|
||||
dataDir,
|
||||
masterKey: masterKey,
|
||||
lyricsProviders: snapshot.lyricsProviders,
|
||||
lyricsFetchOptions: snapshot.lyricsFetchOptions,
|
||||
allowedDirectories: allowedDirectories,
|
||||
);
|
||||
await loadExtensions(extensionsDir);
|
||||
final loadError = state.error;
|
||||
if (loadError != null) throw StateError(loadError);
|
||||
await loadProviderPriority();
|
||||
await loadMetadataProviderPriority();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
await _syncDownloadDirectory(ref.read(settingsProvider));
|
||||
_log.i('Extension system initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize extension system: $e');
|
||||
@@ -153,7 +241,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
|
||||
_log.d('Load extensions result: $result');
|
||||
await refreshExtensions();
|
||||
state = state.copyWith(isLoading: false);
|
||||
state = state.copyWith(isLoading: false, error: state.error);
|
||||
} catch (e) {
|
||||
_log.e('Failed to load extensions: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
@@ -306,6 +394,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await ensureInitialized();
|
||||
final result = await PlatformBridge.loadExtensionFromPath(filePath);
|
||||
_log.i('Installed extension: ${result['name']}');
|
||||
await refreshExtensions();
|
||||
@@ -337,6 +426,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
var installed = 0;
|
||||
final failures = <String, String>{};
|
||||
|
||||
try {
|
||||
await ensureInitialized();
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
return ExtensionInstallBatchResult(
|
||||
attempted: uniquePaths.length,
|
||||
installed: 0,
|
||||
failures: {for (final path in uniquePaths) path: e.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
for (final path in uniquePaths) {
|
||||
try {
|
||||
final result = await PlatformBridge.loadExtensionFromPath(path);
|
||||
@@ -364,6 +464,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||
} catch (e) {
|
||||
_log.e('Failed to check extension upgrade: $e');
|
||||
@@ -375,6 +476,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
await ensureInitialized();
|
||||
final result = await PlatformBridge.upgradeExtension(filePath);
|
||||
_log.i(
|
||||
'Upgraded extension: ${result['display_name']} to v${result['version']}',
|
||||
@@ -566,6 +668,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return const ExtensionRestoreResult();
|
||||
}
|
||||
|
||||
await ensureInitialized();
|
||||
|
||||
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
|
||||
final itemsRaw = data['items'];
|
||||
final items = itemsRaw is List
|
||||
|
||||
@@ -239,6 +239,7 @@ class RepoNotifier extends Notifier<RepoState> {
|
||||
);
|
||||
|
||||
try {
|
||||
await ref.read(extensionProvider.notifier).ensureInitialized();
|
||||
await PlatformBridge.initExtensionRepo(cacheDir);
|
||||
|
||||
if (savedUrl.isNotEmpty) {
|
||||
|
||||
@@ -116,18 +116,34 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
2000,
|
||||
};
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
bool _isSavingSettings = false;
|
||||
bool _saveQueued = false;
|
||||
String? _pendingSettingsJson;
|
||||
Future<void>? _loadSettingsFuture;
|
||||
|
||||
@override
|
||||
AppSettings build() {
|
||||
unawaited(_loadSettings());
|
||||
unawaited(
|
||||
ensureLoaded().catchError((Object error, StackTrace stack) {
|
||||
_log.e('Failed to load settings', error, stack);
|
||||
}),
|
||||
);
|
||||
return ref.read(initialSettingsProvider);
|
||||
}
|
||||
|
||||
/// Startup and extension actions share loading, including preference migrations.
|
||||
Future<void> ensureLoaded() {
|
||||
return _loadSettingsFuture ??= _loadSettings().catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) {
|
||||
_loadSettingsFuture = null;
|
||||
Error.throwWithStackTrace(error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await _prefs;
|
||||
final rawSettings = prefs.getString(_settingsKey);
|
||||
@@ -197,9 +213,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
await syncLyricsSettingsToBackend();
|
||||
await _syncNetworkCompatibilitySettingsToBackend();
|
||||
await _syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
@@ -217,41 +233,34 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.setLyricsFetchOptions({
|
||||
'include_translation_netease': snapshot.lyricsIncludeTranslationNetease,
|
||||
'include_romanization_netease':
|
||||
snapshot.lyricsIncludeRomanizationNetease,
|
||||
'multi_person_word_by_word': snapshot.lyricsMultiPersonWordByWord,
|
||||
'apple_elrc_word_sync': snapshot.lyricsAppleElrcWordSync,
|
||||
'musixmatch_language': snapshot.musixmatchLanguage,
|
||||
});
|
||||
await PlatformBridge.setLyricsFetchOptions(snapshot.lyricsFetchOptions);
|
||||
} catch (e) {
|
||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||
Future<void> _syncNetworkCompatibilitySettingsToBackend() async {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
final compatibilityMode = state.networkCompatibilityMode;
|
||||
PlatformBridge.setNetworkCompatibilityOptions(
|
||||
await PlatformBridge.setNetworkCompatibilityOptions(
|
||||
allowHttp: compatibilityMode,
|
||||
insecureTls: false,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync network compatibility options to backend: $e');
|
||||
});
|
||||
|
||||
PlatformBridge.setAllowPrivateNetwork(state.allowLocalNetwork).catchError((
|
||||
Object e,
|
||||
) {
|
||||
await PlatformBridge.setAllowPrivateNetwork(
|
||||
state.allowLocalNetwork,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync allow local network option to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
void _syncExtensionFallbackSettingsToBackend() {
|
||||
Future<void> _syncExtensionFallbackSettingsToBackend() async {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setDownloadFallbackExtensionIds(
|
||||
await PlatformBridge.setDownloadFallbackExtensionIds(
|
||||
state.downloadFallbackExtensionIds,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync extension fallback settings to backend: $e');
|
||||
@@ -348,8 +357,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
await _syncNetworkCompatibilitySettingsToBackend();
|
||||
await _syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
@@ -727,7 +736,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
extensionIds == null && state.downloadFallbackExtensionIds != null,
|
||||
);
|
||||
_saveSettings();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
unawaited(_syncExtensionFallbackSettingsToBackend());
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
@@ -810,13 +819,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
void setNetworkCompatibilityMode(bool enabled) {
|
||||
state = state.copyWith(networkCompatibilityMode: enabled);
|
||||
_saveSettings();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
unawaited(_syncNetworkCompatibilitySettingsToBackend());
|
||||
}
|
||||
|
||||
void setAllowLocalNetwork(bool enabled) {
|
||||
state = state.copyWith(allowLocalNetwork: enabled);
|
||||
_saveSettings();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
unawaited(_syncNetworkCompatibilitySettingsToBackend());
|
||||
}
|
||||
|
||||
void setSongLinkRegion(String region) {
|
||||
|
||||
@@ -186,7 +186,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
notifier.setDownloadDirectory('');
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: Text(context.l10n.storageModeAppFolder),
|
||||
child: Text(context.l10n.storageAutomaticFolder),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: isPickingFolder
|
||||
|
||||
@@ -139,7 +139,7 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
subtitle: settings.downloadDirectory.isEmpty
|
||||
? (Platform.isIOS
|
||||
? context.l10n.setupAppDocumentsFolder
|
||||
: 'Music/SpotiFLAC')
|
||||
: 'Download/SpotiFLAC')
|
||||
: settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
showDivider: false,
|
||||
@@ -455,20 +455,24 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
}
|
||||
|
||||
Future<String> _getDefaultAndroidDirectory() async {
|
||||
const directMusicPath = '/storage/emulated/0/Music/SpotiFLAC';
|
||||
const directDownloadPath = '/storage/emulated/0/Download/SpotiFLAC';
|
||||
try {
|
||||
final musicDir = Directory(directMusicPath);
|
||||
if (!await musicDir.exists()) await musicDir.create(recursive: true);
|
||||
return musicDir.path;
|
||||
final downloadDir = Directory(directDownloadPath);
|
||||
if (!await downloadDir.exists()) {
|
||||
await downloadDir.create(recursive: true);
|
||||
}
|
||||
return downloadDir.path;
|
||||
} catch (_) {}
|
||||
try {
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
final musicDir = Directory(
|
||||
'${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
||||
final downloadDir = Directory(
|
||||
'${externalDir.parent.parent.parent.parent.path}/Download/SpotiFLAC',
|
||||
);
|
||||
if (!await musicDir.exists()) await musicDir.create(recursive: true);
|
||||
return musicDir.path;
|
||||
if (!await downloadDir.exists()) {
|
||||
await downloadDir.create(recursive: true);
|
||||
}
|
||||
return downloadDir.path;
|
||||
}
|
||||
} catch (_) {}
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
@@ -511,8 +515,8 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||
title: Text(context.l10n.storageModeAppFolder),
|
||||
subtitle: Text(context.l10n.storageModeAppFolderSubtitle),
|
||||
title: Text(context.l10n.storageAutomaticFolder),
|
||||
subtitle: Text(context.l10n.storageAutomaticDownloadFolder),
|
||||
trailing: !isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
@@ -525,8 +529,8 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_open, color: colorScheme.primary),
|
||||
title: Text(context.l10n.storageModeSaf),
|
||||
subtitle: Text(context.l10n.storageModeSafSubtitle),
|
||||
title: Text(context.l10n.storageSafRecommended),
|
||||
subtitle: Text(context.l10n.storageDownloadFolderHint),
|
||||
trailing: isSafMode ? const Icon(Icons.check) : null,
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
|
||||
@@ -640,7 +640,7 @@ class _LogEntryTile extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.actionGo,
|
||||
'Native',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -425,14 +425,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Future<String> _getDefaultDirectory() async {
|
||||
if (Platform.isAndroid) {
|
||||
final musicDir = Directory('/storage/emulated/0/Music/SpotiFLAC');
|
||||
final downloadDir = Directory('/storage/emulated/0/Download/SpotiFLAC');
|
||||
try {
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
if (!await downloadDir.exists()) {
|
||||
await downloadDir.create(recursive: true);
|
||||
}
|
||||
return musicDir.path;
|
||||
return downloadDir.path;
|
||||
} catch (e) {
|
||||
debugPrint('Cannot create Music folder: $e');
|
||||
debugPrint('Cannot create Download folder: $e');
|
||||
}
|
||||
}
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
@@ -923,7 +923,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Widget _buildDirectoryStep(ColorScheme colorScheme) {
|
||||
return _StepLayout(
|
||||
title: context.l10n.setupFolderChoose,
|
||||
description: context.l10n.setupFolderDescription,
|
||||
description: Platform.isAndroid
|
||||
? context.l10n.storageDownloadFolderHint
|
||||
: context.l10n.setupFolderDescription,
|
||||
icon: Icons.create_new_folder,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
@@ -31,16 +31,17 @@ class CoverDownloadService {
|
||||
? 'cover'
|
||||
: safeBaseName.trim();
|
||||
final tempDir = await Directory.systemTemp.createTemp('save_cover_');
|
||||
final tempPath = p.join(tempDir.path, 'cover.image');
|
||||
var tempPath = p.join(tempDir.path, 'cover.image');
|
||||
IosSecurityScopedAccess? iosBookmarkAccess;
|
||||
|
||||
try {
|
||||
final download = await PlatformBridge.downloadCoverToFile(
|
||||
normalizedUrl,
|
||||
tempPath,
|
||||
Platform.isAndroid || Platform.isIOS ? '' : tempPath,
|
||||
);
|
||||
final error = download['error']?.toString().trim() ?? '';
|
||||
if (error.isNotEmpty) throw StateError(error);
|
||||
tempPath = download['file_path'] as String? ?? tempPath;
|
||||
|
||||
final tempFile = File(tempPath);
|
||||
if (!await tempFile.exists() || await tempFile.length() <= 0) {
|
||||
@@ -104,6 +105,8 @@ class CoverDownloadService {
|
||||
await PlatformBridge.stopAccessingIosBookmark(iosBookmarkAccess);
|
||||
}
|
||||
try {
|
||||
final tempFile = File(tempPath);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ class ExtensionStorageService {
|
||||
static Future<ExtensionStoragePaths>? _preparing;
|
||||
|
||||
static Future<ExtensionStoragePaths> prepare() {
|
||||
return _preparing ??= _prepare();
|
||||
return _preparing ??= _prepare().catchError((
|
||||
Object error,
|
||||
StackTrace stack,
|
||||
) {
|
||||
// A transient filesystem/keystore failure must not poison every retry
|
||||
// until the process is restarted.
|
||||
_preparing = null;
|
||||
Error.throwWithStackTrace(error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<ExtensionStoragePaths> _prepare() async {
|
||||
|
||||
@@ -2117,7 +2117,7 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write tags + cover via the native ID3-chunk writer in the Go backend.
|
||||
// Write tags + cover via the native ID3-chunk writer.
|
||||
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||
if (hasMetadata || hasCover) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/utils/isrc_utils.dart' as isrc;
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
|
||||
@@ -507,21 +508,13 @@ class HistoryDatabase {
|
||||
void _putPathKeysInBatch(Batch batch, String id, String? filePath) =>
|
||||
sqlite.putPathKeysInBatch(batch, 'history_path_keys', id, filePath);
|
||||
|
||||
static final _iosContainerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
Future<void> _initContainerPath() async {
|
||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||
|
||||
try {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final match = _iosContainerPattern.firstMatch(docDir.path);
|
||||
if (match != null) {
|
||||
_currentContainerPath = match.group(0);
|
||||
_log.d('iOS container path: $_currentContainerPath');
|
||||
}
|
||||
_currentContainerPath = docDir.parent.path;
|
||||
_log.d('iOS container path: $_currentContainerPath');
|
||||
} catch (e) {
|
||||
_log.w('Failed to get iOS container path: $e');
|
||||
}
|
||||
@@ -531,18 +524,7 @@ class HistoryDatabase {
|
||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||
|
||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||
final normalized = filePath.replaceFirst(
|
||||
_iosContainerPattern,
|
||||
_currentContainerPath!,
|
||||
);
|
||||
if (normalized != filePath) {
|
||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
return rebaseIosSandboxPath(filePath, '$_currentContainerPath/Documents');
|
||||
}
|
||||
|
||||
Future<bool> migrateIosContainerPaths() async {
|
||||
@@ -572,7 +554,7 @@ class HistoryDatabase {
|
||||
final id = row['id'] as String;
|
||||
final oldPath = row['file_path'] as String?;
|
||||
|
||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||
if (oldPath != null) {
|
||||
final newPath = _normalizeIosPath(oldPath);
|
||||
if (newPath != oldPath) {
|
||||
batch.update(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_cleanup.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
@@ -48,6 +49,7 @@ class LibraryDatabase {
|
||||
version: schemaVersion,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
onOpen: _migrateIosContainerPaths,
|
||||
);
|
||||
// Library upserts use INSERT OR REPLACE. Recursive triggers ensure the
|
||||
// implicit delete also decrements materialized lookup ref-counts.
|
||||
@@ -62,6 +64,56 @@ class LibraryDatabase {
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable ?? false;
|
||||
|
||||
Future<void> _migrateIosContainerPaths(Database db) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final documents = await getApplicationDocumentsDirectory();
|
||||
// Keep rows and their lookup keys consistent before any startup cleanup
|
||||
// can mistake a relocated file for a deleted one. Bookmarks remain the
|
||||
// authority for external sources.
|
||||
await db.transaction((txn) async {
|
||||
const localSources = "bookmark IS NULL OR bookmark = ''";
|
||||
final sources = await txn.query(
|
||||
'library_sources',
|
||||
columns: ['id', 'path'],
|
||||
where: localSources,
|
||||
);
|
||||
final rows = await txn.query(
|
||||
'library',
|
||||
columns: ['id', 'file_path', 'cover_path'],
|
||||
where:
|
||||
'source_id IN (SELECT id FROM library_sources WHERE $localSources)',
|
||||
);
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
final updates = <String, Object?>{};
|
||||
for (final column in ['file_path', 'cover_path']) {
|
||||
final previous = row[column] as String?;
|
||||
if (previous == null) continue;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current != previous) updates[column] = current;
|
||||
}
|
||||
if (updates.isEmpty) continue;
|
||||
final id = row['id'] as String;
|
||||
batch.update('library', updates, where: 'id = ?', whereArgs: [id]);
|
||||
if (updates.containsKey('file_path')) {
|
||||
_putPathKeysInBatch(batch, id, updates['file_path'] as String);
|
||||
}
|
||||
}
|
||||
for (final source in sources) {
|
||||
final previous = source['path'] as String;
|
||||
final current = rebaseIosSandboxPath(previous, documents.path);
|
||||
if (current == previous) continue;
|
||||
batch.update(
|
||||
'library_sources',
|
||||
{'path': current},
|
||||
where: 'id = ?',
|
||||
whereArgs: [source['id']],
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensureHistoryAttached(Database db) async {
|
||||
if (_historyAttached) return;
|
||||
await HistoryDatabase.instance.database;
|
||||
|
||||
@@ -727,6 +727,20 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
}
|
||||
|
||||
/// Holds backend access through download and finalization, including folder changes.
|
||||
static Future<String> acquireDownloadDirectory(String path) async {
|
||||
if (!supportsCoreBackend) return '';
|
||||
return await _channel.invokeMethod<String>('acquireDownloadDirectory', {
|
||||
'path': path,
|
||||
}) ??
|
||||
'';
|
||||
}
|
||||
|
||||
static Future<void> releaseDownloadDirectory(String token) async {
|
||||
if (!supportsCoreBackend || token.isEmpty) return;
|
||||
await _channel.invokeMethod('releaseDownloadDirectory', {'token': token});
|
||||
}
|
||||
|
||||
static Future<void> setNetworkCompatibilityOptions({
|
||||
required bool allowHttp,
|
||||
required bool insecureTls,
|
||||
@@ -741,6 +755,17 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('setAllowPrivateNetwork', {'allowed': allowed});
|
||||
}
|
||||
|
||||
/// Reports domain ownership during native backend migration.
|
||||
static Future<Map<String, String>> getBackendImplementations({
|
||||
String? filePath,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, String>(
|
||||
'getBackendImplementations',
|
||||
filePath == null ? null : {'file_path': filePath},
|
||||
);
|
||||
return result ?? const {};
|
||||
}
|
||||
|
||||
static Future<String> buildFilename(
|
||||
String template,
|
||||
Map<String, dynamic> metadata,
|
||||
@@ -1040,9 +1065,11 @@ class PlatformBridge {
|
||||
String audioPath,
|
||||
String outputPath,
|
||||
) {
|
||||
return _invokeMap('extractCoverToFile', {
|
||||
'audio_path': audioPath,
|
||||
'output_path': outputPath,
|
||||
return _withMediaFileAccess([audioPath, outputPath], () {
|
||||
return _invokeMap('extractCoverToFile', {
|
||||
'audio_path': audioPath,
|
||||
'output_path': outputPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1100,7 +1127,12 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> reEnrichFile(
|
||||
Map<String, dynamic> request,
|
||||
) {
|
||||
return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
|
||||
Future<Map<String, dynamic>> invoke() =>
|
||||
_invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
|
||||
if (request['preview_only'] == true) return invoke();
|
||||
return _withMediaFileAccess([
|
||||
request['file_path'] as String? ?? '',
|
||||
], invoke);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> readFileMetadata(
|
||||
@@ -1136,12 +1168,41 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
return _invokeMap('editFileMetadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
return _withMediaFileAccess([filePath, metadata['cover_path'] ?? ''], () {
|
||||
return _invokeMap('editFileMetadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static Future<T> _withMediaFileAccess<T>(
|
||||
Iterable<String> paths,
|
||||
Future<T> Function() operation,
|
||||
) async {
|
||||
// A user-selected library file or cover can live outside the download
|
||||
// folder. Hold only this operation's backend grants; SAF is staged natively.
|
||||
final scopes = <String>[];
|
||||
try {
|
||||
final directories = <String>{
|
||||
for (final path in paths)
|
||||
if (path.startsWith('/')) File(path).parent.path,
|
||||
};
|
||||
for (final directory in directories) {
|
||||
scopes.add(await acquireDownloadDirectory(directory));
|
||||
}
|
||||
return await operation();
|
||||
} finally {
|
||||
for (final scope in scopes.reversed) {
|
||||
try {
|
||||
await releaseDownloadDirectory(scope);
|
||||
} catch (e) {
|
||||
_log.w('Failed to release media file access: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms.
|
||||
/// FFmpeg's MP4 muxer drops these keys, so they must be written natively
|
||||
/// after the FFmpeg metadata pass. [filePath] must be a local file path.
|
||||
@@ -1150,9 +1211,11 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
Map<String, String> fields,
|
||||
) {
|
||||
return _invokeMap('writeM4AFreeformTags', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(fields),
|
||||
return _withMediaFileAccess([filePath], () {
|
||||
return _invokeMap('writeM4AFreeformTags', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(fields),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1164,9 +1227,11 @@ class PlatformBridge {
|
||||
String filePath,
|
||||
String sourcePath,
|
||||
) {
|
||||
return _invokeMap('ensureAC4Config', {
|
||||
'file_path': filePath,
|
||||
'source_path': sourcePath,
|
||||
return _withMediaFileAccess([filePath, sourcePath], () {
|
||||
return _invokeMap('ensureAC4Config', {
|
||||
'file_path': filePath,
|
||||
'source_path': sourcePath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1179,24 +1244,28 @@ class PlatformBridge {
|
||||
Map<String, String> metadata,
|
||||
String coverPath,
|
||||
) {
|
||||
return _invokeMap('writeAC4Metadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
'cover_path': coverPath,
|
||||
return _withMediaFileAccess([filePath, coverPath], () {
|
||||
return _invokeMap('writeAC4Metadata', {
|
||||
'file_path': filePath,
|
||||
'metadata_json': jsonEncode(metadata),
|
||||
'cover_path': coverPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
|
||||
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
/// using the selected native FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
|
||||
String filePath,
|
||||
String artist,
|
||||
String albumArtist,
|
||||
) {
|
||||
return _invokeMap('rewriteSplitArtistTags', {
|
||||
'file_path': filePath,
|
||||
'artist': artist,
|
||||
'album_artist': albumArtist,
|
||||
return _withMediaFileAccess([filePath], () {
|
||||
return _invokeMap('rewriteSplitArtistTags', {
|
||||
'file_path': filePath,
|
||||
'artist': artist,
|
||||
'album_artist': albumArtist,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1493,7 +1562,7 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearLogs');
|
||||
}
|
||||
|
||||
/// Ask the Go backend to GC and return freed heap to the OS. Best-effort:
|
||||
/// Ask the native backend to release unused memory. Best-effort:
|
||||
/// safe to call on memory pressure or when the app is backgrounded.
|
||||
static Future<void> releaseNativeMemory({bool underPressure = false}) async {
|
||||
try {
|
||||
@@ -1524,12 +1593,18 @@ class PlatformBridge {
|
||||
String extensionsDir,
|
||||
String dataDir, {
|
||||
required String masterKey,
|
||||
required List<String> lyricsProviders,
|
||||
required Map<String, dynamic> lyricsFetchOptions,
|
||||
List<String> allowedDirectories = const [],
|
||||
}) async {
|
||||
_log.d('initExtensionSystem: $extensionsDir, $dataDir');
|
||||
await _channel.invokeMethod('initExtensionSystem', {
|
||||
'extensions_dir': extensionsDir,
|
||||
'data_dir': dataDir,
|
||||
'master_key': masterKey,
|
||||
'lyrics_providers_json': jsonEncode(lyricsProviders),
|
||||
'lyrics_options_json': jsonEncode(lyricsFetchOptions),
|
||||
'allowed_directories': allowedDirectories,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2513,17 +2588,34 @@ class PlatformBridge {
|
||||
static Future<Map<String, dynamic>> runPostProcessingV2(
|
||||
String filePath, {
|
||||
Map<String, dynamic>? metadata,
|
||||
String? itemId,
|
||||
}) async {
|
||||
final input = <String, dynamic>{};
|
||||
if (itemId != null && itemId.isNotEmpty) input['item_id'] = itemId;
|
||||
if (filePath.startsWith('content://')) {
|
||||
input['uri'] = filePath;
|
||||
} else {
|
||||
input['path'] = filePath;
|
||||
}
|
||||
return _invokeMap('runPostProcessingV2', {
|
||||
final result = await _invokeMap('runPostProcessingV2', {
|
||||
'input': jsonEncode(input),
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : '',
|
||||
});
|
||||
if (result['success'] != true) return result;
|
||||
|
||||
// The Go/Rust result uses new_file_path; the queue consumes file_path.
|
||||
// SAF publishes back to its document URI after processing a temporary file.
|
||||
final uri = result['new_file_uri'];
|
||||
final path = result['new_file_path'];
|
||||
final legacyPath = result['file_path'];
|
||||
if (filePath.startsWith('content://') && uri is String && uri.isNotEmpty) {
|
||||
result['file_path'] = uri;
|
||||
} else if (path is String && path.isNotEmpty) {
|
||||
result['file_path'] = path;
|
||||
} else if (legacyPath is! String || legacyPath.isEmpty) {
|
||||
result['file_path'] = filePath;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<void> initExtensionRepo(String cacheDir) async {
|
||||
|
||||
@@ -50,6 +50,7 @@ Future<Database> openAppDatabase(
|
||||
required Future<void> Function(Database db, int version) onCreate,
|
||||
required Future<void> Function(Database db, int oldVersion, int newVersion)
|
||||
onUpgrade,
|
||||
Future<void> Function(Database db)? onOpen,
|
||||
bool foreignKeys = false,
|
||||
bool incrementalAutoVacuum = true,
|
||||
}) async {
|
||||
@@ -94,6 +95,7 @@ Future<Database> openAppDatabase(
|
||||
},
|
||||
onCreate: onCreate,
|
||||
onUpgrade: onUpgrade,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,12 @@ Future<bool> applyFfmpegReEnrichResult({
|
||||
|
||||
try {
|
||||
if (!_hasValue(effectiveCoverPath)) {
|
||||
Directory? extractionDirectory;
|
||||
try {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'reenrich_cover_',
|
||||
);
|
||||
extractionDirectory = tempDir;
|
||||
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
final extracted = await PlatformBridge.extractCoverToFile(
|
||||
ffmpegTarget,
|
||||
@@ -61,12 +63,15 @@ Future<bool> applyFfmpegReEnrichResult({
|
||||
if (extracted['error'] == null) {
|
||||
effectiveCoverPath = coverOutput;
|
||||
extractedCoverPath = coverOutput;
|
||||
} else {
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (extractedCoverPath == null && extractionDirectory != null) {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
await extractionDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:open_filex/open_filex.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
|
||||
/// Whether the queue should run its iOS output-directory path-shape check
|
||||
@@ -65,7 +66,7 @@ String _joinRecoveredIosPath(String documentsPath, String suffix) {
|
||||
/// 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 an iCloud Drive path (not accessible by the backend)
|
||||
/// - The path is outside the app sandbox
|
||||
bool isValidIosWritablePath(String path) {
|
||||
if (!Platform.isIOS) return true;
|
||||
@@ -109,8 +110,8 @@ Future<String> validateOrFixIosPath(
|
||||
}) async {
|
||||
if (!Platform.isIOS) return path;
|
||||
|
||||
final trimmed = path.trim();
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final trimmed = rebaseIosSandboxPath(path.trim(), docDir.path);
|
||||
|
||||
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
|
||||
trimmed,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
final _iosSandboxRoot = RegExp(
|
||||
r'^(?:/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+(?=/)'
|
||||
r'|^/[^\n]+/Library/Developer/CoreSimulator/Devices/[A-F0-9\-]+'
|
||||
r'/data/Containers/Data/Application/[A-F0-9\-]+(?=/)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Restores an app-owned path after iOS relocates the app's data container.
|
||||
/// Bookmarked external folders must be resolved through their bookmark instead.
|
||||
String rebaseIosSandboxPath(String path, String documentsPath) {
|
||||
final current = _iosSandboxRoot.firstMatch(documentsPath);
|
||||
final previous = _iosSandboxRoot.firstMatch(path);
|
||||
if (current == null ||
|
||||
previous == null ||
|
||||
documentsPath.substring(current.end) != '/Documents') {
|
||||
return path;
|
||||
}
|
||||
|
||||
final suffix = path.substring(previous.end);
|
||||
const appDirectories = [
|
||||
'/Documents',
|
||||
'/Library/Application Support',
|
||||
'/Library/Caches',
|
||||
];
|
||||
if (!appDirectories.any(
|
||||
(directory) => suffix == directory || suffix.startsWith('$directory/'),
|
||||
) ||
|
||||
suffix.split('/').contains('..')) {
|
||||
return path;
|
||||
}
|
||||
return '${documentsPath.substring(0, current.end)}$suffix';
|
||||
}
|
||||
@@ -106,8 +106,8 @@ class LogEntry {
|
||||
@override
|
||||
String toString() {
|
||||
final errorPart = error != null ? ' | $error' : '';
|
||||
final goPart = isFromGo ? ' [Go]' : '';
|
||||
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
|
||||
final nativePart = isFromGo ? ' [Native]' : '';
|
||||
return '[$formattedTime] [$level]$nativePart [$tag] $message$errorPart';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final timestamp = logMap['timestamp'] as String? ?? '';
|
||||
final tag = logMap['tag'] as String? ?? 'Go';
|
||||
final tag = logMap['tag'] as String? ?? 'Native';
|
||||
final message = logMap['message'] as String? ?? '';
|
||||
|
||||
DateTime parsedTime = DateTime.now();
|
||||
@@ -245,7 +245,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
_lastGoLogIndex = nextIndex;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to fetch Go logs: $e');
|
||||
debugPrint('Failed to fetch native backend logs: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
buffer.writeln('Warnings: $warnCount');
|
||||
buffer.writeln('Info: $infoCount');
|
||||
buffer.writeln('Debug: $debugCount');
|
||||
buffer.writeln('From Go Backend: $goCount');
|
||||
buffer.writeln('From Native Backend: $goCount');
|
||||
buffer.writeln();
|
||||
|
||||
buffer.writeln('=' * 60);
|
||||
|
||||
@@ -83,7 +83,7 @@ String _sidecarLrcPath(String path) {
|
||||
return '$path.lrc';
|
||||
}
|
||||
|
||||
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the Go backend
|
||||
/// Writes a ".lrc" sidecar next to a re-enriched audio file when the backend
|
||||
/// result requests it (`write_external_lrc`), honoring the user's lyrics mode.
|
||||
///
|
||||
/// This handles the filesystem case only. SAF (`content://`) files are written
|
||||
|
||||
Reference in new issue
Block a user