worldhopper/lib/services/background_download_service.dart
Felipe M. d2497f8aed
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
feat: add background download service with error handling improvements
Add Android foreground service support for downloads to prevent OS
process killing during long operations. Fix multiple error handling
issues: foreground service leak on exceptions, polling stuck forever
on failure, double-counting chapters on retry, silent retry failures
without user feedback, and app crash if background service init fails.
Replace inaccurate failed chapter count heuristic with actual data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:44:37 +01:00

211 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const _notificationChannelId = 'worldhopper_downloads';
const _notificationId = 888;
const _completeNotificationId = 889;
/// Plugin instance for showing rich notifications from the main isolate.
FlutterLocalNotificationsPlugin? _flnPlugin;
/// Timestamp of the last progress notification update (for throttling).
DateTime _lastProgressUpdate = DateTime(0);
/// Initializes the background service configuration.
/// Must be called once at app startup.
Future<void> initializeBackgroundService() async {
final service = FlutterBackgroundService();
if (Platform.isAndroid) {
final plugin = FlutterLocalNotificationsPlugin();
const channel = AndroidNotificationChannel(
_notificationChannelId,
'Downloads',
description: 'Worldhopper download progress',
importance: Importance.low,
);
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
const initSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
await plugin.initialize(
const InitializationSettings(android: initSettings),
);
_flnPlugin = plugin;
}
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: _onStart,
autoStart: false,
isForegroundMode: true,
notificationChannelId: _notificationChannelId,
initialNotificationTitle: 'Worldhopper',
initialNotificationContent: 'Downloading...',
foregroundServiceNotificationId: _notificationId,
foregroundServiceTypes: [AndroidForegroundType.dataSync],
),
iosConfiguration: IosConfiguration(
autoStart: false,
onForeground: _onStart,
onBackground: _onIosBackground,
),
);
}
@pragma('vm:entry-point')
Future<bool> _onIosBackground(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
return true;
}
/// Background isolate entry point.
/// This isolate does NO work itself — it only keeps the Android foreground
/// service (and its notification) alive. The main isolate sends
/// `stopService` events. Notification updates are handled directly
/// via flutter_local_notifications from the main isolate.
@pragma('vm:entry-point')
void _onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
// Main isolate tells us to stop when all downloads finish
service.on('stopService').listen((_) {
service.stopSelf();
});
}
// ---------------------------------------------------------------------------
// Public helpers called from the main isolate
// ---------------------------------------------------------------------------
/// Start the foreground service so Android keeps the process alive.
Future<void> startForegroundService() async {
if (!Platform.isAndroid) return;
try {
final service = FlutterBackgroundService();
final running = await service.isRunning();
if (!running) {
await service.startService();
}
} catch (e) {
debugPrint('Could not start foreground service: $e');
}
}
/// Update the foreground notification from the main isolate.
///
/// When [progress] and [maxProgress] are provided, shows a native progress
/// bar. Updates are throttled to at most once per 500ms unless [force] is true.
void updateDownloadNotification({
required String title,
required String content,
int? progress,
int? maxProgress,
bool force = false,
}) {
if (!Platform.isAndroid) return;
// Throttle: skip if <500ms since last update (unless forced)
final now = DateTime.now();
if (!force && now.difference(_lastProgressUpdate).inMilliseconds < 500) {
return;
}
_lastProgressUpdate = now;
// Try rich notification via flutter_local_notifications
if (_flnPlugin != null && progress != null && maxProgress != null) {
final details = AndroidNotificationDetails(
_notificationChannelId,
'Downloads',
channelDescription: 'Worldhopper download progress',
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: maxProgress,
progress: progress,
ongoing: true,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
_flnPlugin!
.show(
_notificationId,
title,
content,
NotificationDetails(android: details),
)
.catchError(
(e) => debugPrint('Failed to show notification: $e'),
);
return;
}
// Fallback: text-only via background service
try {
final service = FlutterBackgroundService();
service.invoke('updateNotification', {
'title': title,
'content': content,
});
} catch (e) {
debugPrint('Notification fallback failed: $e');
}
}
/// Dismiss the progress notification (e.g. when download finishes).
void dismissDownloadNotification() {
if (!Platform.isAndroid) return;
_flnPlugin?.cancel(_notificationId);
}
/// Show a brief "download complete" notification that auto-dismisses on tap.
void showDownloadCompleteNotification({
required String title,
required String body,
}) {
if (!Platform.isAndroid || _flnPlugin == null) return;
const details = AndroidNotificationDetails(
_notificationChannelId,
'Downloads',
channelDescription: 'Worldhopper download progress',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
icon: '@mipmap/ic_launcher',
);
_flnPlugin!
.show(
_completeNotificationId,
title,
body,
const NotificationDetails(android: details),
)
.catchError(
(e) => debugPrint('Failed to show completion notification: $e'),
);
}
/// Stop the foreground service (e.g. when all downloads finish).
Future<void> stopForegroundService() async {
if (!Platform.isAndroid) return;
try {
final service = FlutterBackgroundService();
final running = await service.isRunning();
if (running) {
service.invoke('stopService');
}
} catch (e) {
debugPrint('Could not stop foreground service: $e');
}
}