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:
zarzet
2026-06-13 02:42:23 +07:00
parent ca413a16fa
commit 6b5345a6e5
9 changed files with 213 additions and 20 deletions
+36 -11
View File
@@ -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 {
+39 -1
View File
@@ -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,
+7 -2
View File
@@ -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(
+7 -2
View File
@@ -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(
+10 -2
View File
@@ -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);
+20
View File
@@ -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 {},