mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-29 17:50:00 +02:00
fix(downloads/extensions): iOS background task, serialize extension mutations, safer batch convert sheet
- iOS: begin/end UIBackgroundTask while a download queue is active so in-flight downloads survive backgrounding for the limited window iOS allows - extensions: serialize install/upgrade/remove in the Go manager (mutationMu) and in the Dart store provider to stop concurrent goja VM teardown/reload from hard-crashing the app - main: add runZonedGuarded + FlutterError/PlatformDispatcher onError so uncaught Dart errors are logged, not fatal - batch convert sheet: precompute localized title/label before showModalBottomSheet to avoid Localizations lookup via a deactivated context
This commit is contained in:
@@ -118,7 +118,13 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
}
|
||||
|
||||
type extensionManager struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
// mutationMu serializes heavy mutating operations (install / upgrade /
|
||||
// remove). These tear down and re-extract files and rebuild goja VMs;
|
||||
// running two at once (e.g. updating two extensions simultaneously) races
|
||||
// the non-thread-safe goja runtime and can hard-crash the process. Always
|
||||
// acquired before m.mu; internal "*Locked" helpers assume it is held.
|
||||
mutationMu sync.Mutex
|
||||
extensions map[string]*loadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
@@ -156,6 +162,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
}
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.loadExtensionFromFileLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -212,7 +224,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
return m.UpgradeExtension(filePath)
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
} else {
|
||||
@@ -736,6 +748,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
||||
}
|
||||
|
||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -756,6 +771,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ import Gobackend // Import Go framework
|
||||
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
private var activeSecurityScopedURL: URL?
|
||||
|
||||
/// Whether a download queue is currently active. When true, the app begins a
|
||||
/// fresh background task each time it enters the background so an in-flight
|
||||
/// download keeps running for the limited window iOS allows. iOS has no
|
||||
/// foreground-service equivalent, so this is best-effort. Only touched on the
|
||||
/// main thread.
|
||||
private var downloadsActive = false
|
||||
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -233,6 +241,25 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
|
||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
// Background-task management must run on the main thread and does not
|
||||
// touch the Go backend, so handle it directly without dispatching.
|
||||
switch call.method {
|
||||
case "beginBackgroundDownloadTask":
|
||||
// Queue started: remember it so we extend background time whenever
|
||||
// the app is backgrounded while downloads run.
|
||||
downloadsActive = true
|
||||
result(nil)
|
||||
return
|
||||
case "endBackgroundDownloadTask":
|
||||
// Queue finished/paused: stop extending background time.
|
||||
downloadsActive = false
|
||||
endBackgroundDownloadTask()
|
||||
result(nil)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let response = try self.invokeGoMethod(call: call)
|
||||
@@ -246,6 +273,39 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
super.applicationDidEnterBackground(application)
|
||||
if downloadsActive {
|
||||
beginBackgroundDownloadTask()
|
||||
}
|
||||
}
|
||||
|
||||
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
super.applicationWillEnterForeground(application)
|
||||
// No background-time countdown while in the foreground.
|
||||
endBackgroundDownloadTask()
|
||||
}
|
||||
|
||||
/// Begins a background task (if one is not already active) so an in-flight
|
||||
/// download keeps running for the limited window iOS allows after the app is
|
||||
/// backgrounded. The expiration handler ends the task to avoid the app being
|
||||
/// force-terminated by the watchdog. Must run on the main thread.
|
||||
private func beginBackgroundDownloadTask() {
|
||||
if downloadBackgroundTask != .invalid { return }
|
||||
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
|
||||
withName: "SpotiFLACDownloads"
|
||||
) { [weak self] in
|
||||
self?.endBackgroundDownloadTask()
|
||||
}
|
||||
}
|
||||
|
||||
private func endBackgroundDownloadTask() {
|
||||
if downloadBackgroundTask != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
|
||||
downloadBackgroundTask = .invalid
|
||||
}
|
||||
}
|
||||
|
||||
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
|
||||
var error: NSError?
|
||||
|
||||
+36
-11
@@ -15,20 +15,45 @@ import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
final _log = AppLogger('Main');
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
void main() {
|
||||
// Catch uncaught errors so a failure in a provider/async path (e.g. a
|
||||
// misbehaving extension request) is logged instead of taking the whole app
|
||||
// down. Native (Go) fatal crashes can't be caught here, but Dart-side ones
|
||||
// can.
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
previousOnError?.call(details);
|
||||
_log.e('Uncaught Flutter error: ${details.exceptionAsString()}');
|
||||
};
|
||||
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||
_log.e('Uncaught platform error: $error');
|
||||
return true;
|
||||
};
|
||||
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
(error, stack) {
|
||||
_log.e('Uncaught zone error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6676,6 +6676,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// iOS has no foreground service; request a background execution window so
|
||||
// an in-flight download can keep running for a short time after the app is
|
||||
// backgrounded instead of being suspended immediately.
|
||||
if (Platform.isIOS && _totalQueuedAtStart > 0) {
|
||||
await PlatformBridge.beginBackgroundDownloadTask();
|
||||
}
|
||||
|
||||
if (!isSafMode && state.outputDir.isEmpty) {
|
||||
_log.d('Output dir empty, initializing...');
|
||||
await _initOutputDir();
|
||||
@@ -6794,6 +6801,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await PlatformBridge.endBackgroundDownloadTask();
|
||||
}
|
||||
|
||||
if (_downloadCount > 0) {
|
||||
_log.d('Final connection cleanup...');
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -205,6 +206,24 @@ class StoreState {
|
||||
}
|
||||
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
/// Serializes extension install/upgrade operations. Running two of these at
|
||||
/// once (e.g. updating two extensions simultaneously) races the native
|
||||
/// extension manager's goja VM teardown/reload and can hard-crash the app, so
|
||||
/// they must run strictly one at a time.
|
||||
Future<void> _mutationChain = Future<void>.value();
|
||||
|
||||
Future<T> _runSerialized<T>(Future<T> Function() action) {
|
||||
final completer = Completer<T>();
|
||||
_mutationChain = _mutationChain.then((_) async {
|
||||
try {
|
||||
completer.complete(await action());
|
||||
} catch (e, st) {
|
||||
completer.completeError(e, st);
|
||||
}
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
StoreState build() {
|
||||
return const StoreState();
|
||||
@@ -330,6 +349,16 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) {
|
||||
return _runSerialized(
|
||||
() => _installExtensionInternal(extensionId, tempDir, extensionsDir),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _installExtensionInternal(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
@@ -366,7 +395,16 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) {
|
||||
return _runSerialized(
|
||||
() => _updateExtensionInternal(extensionId, tempDir),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _updateExtensionInternal(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
|
||||
@@ -968,6 +968,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
@@ -976,8 +981,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
title: sheetTitle,
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
|
||||
@@ -1216,6 +1216,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
@@ -1224,8 +1229,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
title: sheetTitle,
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
onConvert: (format, bitrate) {
|
||||
Navigator.pop(sheetContext);
|
||||
_performBatchConversion(
|
||||
|
||||
@@ -4849,6 +4849,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
var didStartConversion = false;
|
||||
|
||||
// Resolve localized strings up front: the builder closure must not look up
|
||||
// Localizations via the outer (State) context, which may be deactivated by
|
||||
// the time the root-navigator sheet rebuilds.
|
||||
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
|
||||
final sheetConfirmLabel = context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
);
|
||||
|
||||
_suppressSelectionOverlay = true;
|
||||
_hideSelectionOverlay();
|
||||
_hidePlaylistSelectionOverlay();
|
||||
@@ -4861,8 +4869,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
builder: (sheetContext) => BatchConvertSheet(
|
||||
formats: formats,
|
||||
title: context.l10n.selectionBatchConvertConfirmTitle,
|
||||
confirmLabel: context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
title: sheetTitle,
|
||||
confirmLabel: sheetConfirmLabel,
|
||||
onConvert: (format, bitrate) {
|
||||
didStartConversion = true;
|
||||
Navigator.pop(sheetContext);
|
||||
|
||||
@@ -865,6 +865,26 @@ class PlatformBridge {
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// iOS only: mark that a download queue is active so the app keeps in-flight
|
||||
/// downloads running for a short window whenever it is backgrounded. iOS
|
||||
/// grants a limited budget (roughly 30s on modern versions) per background
|
||||
/// entry; there is no foreground-service equivalent, so this is best-effort.
|
||||
static Future<void> beginBackgroundDownloadTask() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
await _channel.invokeMethod('beginBackgroundDownloadTask');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS only: mark that the download queue is no longer active (queue finished
|
||||
/// or paused), stopping background-time extension.
|
||||
static Future<void> endBackgroundDownloadTask() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
await _channel.invokeMethod('endBackgroundDownloadTask');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> startNativeDownloadWorker({
|
||||
required List<Map<String, dynamic>> requests,
|
||||
Map<String, dynamic> settings = const {},
|
||||
|
||||
Reference in New Issue
Block a user