All checks were successful
ci/woodpecker/tag/release Pipeline was successful
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>
211 lines
6.2 KiB
Dart
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');
|
|
}
|
|
}
|