feat: foreground service on Android

This commit is contained in:
Tien Do Nam
2026-07-26 02:44:34 +02:00
parent 3247562cc5
commit 8296237a6b
22 changed files with 723 additions and 41 deletions
@@ -0,0 +1,14 @@
extension IntFileSize on int {
/// Converts the integer representing bytes to a readable string
String get asReadableFileSize {
if (this < 1024) {
return '$this B';
} else if (this < 1024 * 1024) {
return '${(this / 1024).toStringAsFixed(1)} KB';
} else if (this < 1024 * 1024 * 1024) {
return '${(this / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(this / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
}
@@ -0,0 +1,52 @@
import 'package:localsend_isolates/util/notification_strings.dart';
const _millisecondsPerSecond = 1000;
const _secondsPerMinute = 60;
const _secondsPerHour = 3600;
const _secondsPerDay = 86400;
int getFileSpeed({
required int start,
required int end,
required int bytes,
}) {
final deltaTime = end - start;
return (_millisecondsPerSecond * bytes) ~/ deltaTime;
}
String getRemainingTime({
required int bytesPerSeconds,
required int remainingBytes,
required NotificationStrings strings,
}) {
if (bytesPerSeconds == 0) {
return remainingBytes == 0 ? strings.remainingTimeSeconds(n: 0, ss: '00') : '';
}
final remainingTimeInSeconds = _getRemainingTime(bytesPerSeconds: bytesPerSeconds, remainingBytes: remainingBytes);
if (remainingTimeInSeconds < _secondsPerMinute) {
return strings.remainingTimeSeconds(n: 0, ss: remainingTimeInSeconds.toString().padLeft(2, '0'));
} else if (remainingTimeInSeconds < _secondsPerHour) {
final minutes = remainingTimeInSeconds ~/ _secondsPerMinute;
final seconds = remainingTimeInSeconds % _secondsPerMinute;
return strings.remainingTimeMinutes(n: minutes, ss: seconds.toString().padLeft(2, '0'));
} else if (remainingTimeInSeconds < _secondsPerDay) {
final hours = remainingTimeInSeconds ~/ _secondsPerHour;
final minutes = (remainingTimeInSeconds % _secondsPerHour) ~/ _secondsPerMinute;
return strings.remainingTimeHours(h: hours, m: minutes);
} else {
final days = remainingTimeInSeconds ~/ _secondsPerDay;
final remainingAfterDays = remainingTimeInSeconds % _secondsPerDay;
final hours = remainingAfterDays ~/ _secondsPerHour;
final minutes = (remainingAfterDays % _secondsPerHour) ~/ _secondsPerMinute;
return strings.remainingTimeDays(d: days, h: hours, m: minutes);
}
}
int _getRemainingTime({
required int bytesPerSeconds,
required int remainingBytes,
}) {
return remainingBytes ~/ bytesPerSeconds;
}
@@ -0,0 +1,191 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:localsend_isolates/util/future_queue.dart';
import 'package:logging/logging.dart';
final _logger = Logger('ForegroundService');
const _channelId = 'localsend_foreground_service';
const _serviceId = 1;
/// Notifications cannot sensibly be redrawn as often as transfer progress arrives.
const _updateInterval = Duration(milliseconds: 500);
/// An Android foreground service that keeps the app process alive.
class ForegroundService {
ForegroundService._();
/// Start, update and stop must not overlap, otherwise they would read a [_running] flag that
/// a still pending action is about to change.
static final _queue = FutureQueue(
onError: (e, st) => _logger.warning('Foreground service operation failed', e, st),
);
static bool _initialized = false;
static bool _running = false;
static DateTime? _lastUpdate;
static String? _lastTitle;
static String? _lastText;
/// The service exists on Android only. On iOS the app keeps running in the background anyway,
/// and on desktop there is nothing to keep alive.
static bool get _isSupported => defaultTargetPlatform == TargetPlatform.android;
/// Whether the service is currently keeping the process alive.
static bool get isRunning => _running;
/// Whether [updateNotification] would actually forward an update right now.
/// Lets callers skip building a text that gets throttled away anyway.
static bool get shouldUpdateNotification {
if (!_isSupported || !_running) {
return false;
}
final lastUpdate = _lastUpdate;
return lastUpdate == null || DateTime.now().difference(lastUpdate) >= _updateInterval;
}
/// Starts the service and shows the notification. Does nothing if it is already running.
///
/// [channelName] is shown in the Android notification settings and is only read the first time
/// the service starts, because the notification channel is created once per app installation.
static void start({
required String channelName,
required String title,
required String text,
}) {
if (!_isSupported) {
return;
}
_lastUpdate = null;
_lastTitle = null;
_lastText = null;
_queue.add(() async {
if (_running) {
return;
}
await _requestNotificationPermission();
_init(channelName: channelName);
final result = await FlutterForegroundTask.startService(
serviceId: _serviceId,
serviceTypes: const [ForegroundServiceTypes.dataSync],
notificationTitle: title,
notificationText: text,
);
if (result is ServiceRequestFailure) {
// Most likely the app was in the background (Android 12+ forbids starting a foreground
// service from there). Whatever the service was meant to protect is unaffected.
_logger.warning('Could not start the foreground service', result.error);
return;
}
_running = true;
});
}
/// Updates the notification.
/// Throttled to [_updateInterval]; calls before the service is running are dropped.
///
/// The [title] can change while the service runs, because what the service is keeping alive
/// may change without it ever stopping.
static void updateNotification({required String title, required String text}) {
if (!_isSupported) {
return;
}
final now = DateTime.now();
final lastUpdate = _lastUpdate;
if (lastUpdate != null && now.difference(lastUpdate) < _updateInterval) {
return;
}
_lastUpdate = now;
if (title == _lastTitle && text == _lastText) {
return;
}
_lastTitle = title;
_lastText = text;
_queue.add(() async {
if (!_running) {
return;
}
final result = await FlutterForegroundTask.updateService(notificationTitle: title, notificationText: text);
if (result is ServiceRequestFailure) {
_logger.warning('Could not update the foreground service', result.error);
}
});
}
/// Stops the service and removes the notification. Does nothing if it is not running.
static void stop() {
if (!_isSupported) {
return;
}
_lastUpdate = null;
_lastTitle = null;
_lastText = null;
_queue.add(() async {
if (!_running) {
return;
}
_running = false;
final result = await FlutterForegroundTask.stopService();
if (result is ServiceRequestFailure) {
_logger.warning('Could not stop the foreground service', result.error);
}
});
}
static void _init({required String channelName}) {
if (_initialized) {
return;
}
_initialized = true;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: _channelId,
channelName: channelName,
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
onlyAlertOnce: true,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: false,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
// The work happens in the other isolates, so the service has nothing to do on its own.
eventAction: ForegroundTaskEventAction.nothing(),
autoRunOnBoot: false,
autoRunOnMyPackageReplaced: false,
allowWakeLock: true,
allowWifiLock: true,
),
);
}
/// Android 13+ needs this permission to show the notification.
/// The service itself runs either way, so a missing permission is not treated as an error.
///
/// Must not overlap with another permission request: Android cancels the pending dialog and
/// reports an empty result, which the plugin surfaces as a `PermissionRequestCancelledException`.
static Future<void> _requestNotificationPermission() async {
try {
// Only ask while the user has not decided yet. Once permanently denied, the permission can
// only be changed in the system settings and asking again silently resolves to denied.
if (await FlutterForegroundTask.checkNotificationPermission() == NotificationPermission.denied) {
await FlutterForegroundTask.requestNotificationPermission();
}
} catch (e) {
_logger.warning('Could not request the notification permission', e);
}
}
}
@@ -0,0 +1,43 @@
import 'dart:async';
import 'dart:collection';
/// Runs asynchronous actions one after another, in the order they were added.
class FutureQueue {
FutureQueue({this.onError});
/// Called when an action throws. The queue continues with the next action either way.
/// When omitted, errors are silently swallowed.
final void Function(Object error, StackTrace stackTrace)? onError;
final Queue<Future<void> Function()> _queue = Queue();
bool _draining = false;
/// Adds [action] to the end of the queue.
/// It starts once every action added before it has finished.
void add(Future<void> Function() action) {
_queue.add(action);
if (!_draining) {
// ignore: discarded_futures
_drain();
}
}
/// Drops all actions that have not started yet. A running action is not interrupted.
void clear() {
_queue.clear();
}
/// Processes the queue one by one until it runs empty.
Future<void> _drain() async {
_draining = true;
while (_queue.isNotEmpty) {
final action = _queue.removeFirst();
try {
await action();
} catch (e, st) {
onError?.call(e, st);
}
}
_draining = false;
}
}
@@ -0,0 +1,33 @@
/// The translated strings that this package needs but cannot produce on its own: the app owns the
/// translations, and the dependency only points from the app to this package.
///
/// Inject an instance via `TransferNotification.init`. The signatures mirror the ones slang
/// generates, so the app can hand over its translation members directly.
class NotificationStrings {
/// Title while files are being received, e.g. "Receiving files".
final String titleReceiving;
/// Title while files are being sent, e.g. "Sending files".
final String titleSending;
/// Remaining time below a minute, e.g. "0:45". [ss] is zero padded.
final String Function({required Object n, required Object ss}) remainingTimeSeconds;
/// Remaining time below an hour, e.g. "1:30". [ss] is zero padded.
final String Function({required Object n, required Object ss}) remainingTimeMinutes;
/// Remaining time below a day, e.g. "2h 5m".
final String Function({required Object h, required Object m}) remainingTimeHours;
/// Remaining time of a day or more, e.g. "3d 4h 5m".
final String Function({required Object d, required Object h, required Object m}) remainingTimeDays;
const NotificationStrings({
required this.titleReceiving,
required this.titleSending,
required this.remainingTimeSeconds,
required this.remainingTimeMinutes,
required this.remainingTimeHours,
required this.remainingTimeDays,
});
}
@@ -0,0 +1,160 @@
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:localsend_isolates/util/file_speed_helper.dart';
import 'package:localsend_isolates/util/foreground_service.dart';
import 'package:localsend_isolates/util/notification_strings.dart';
/// A transfer only becomes fast enough to measure after a while; the first chunks are not
/// representative. Same threshold as `ProgressPage`.
const _minBytesForSpeed = 500 * 1024;
/// Drives the single foreground service notification on behalf of all running transfers.
///
/// There is one notification but there can be several transfers: receiving is limited to one
/// session, sending is not, and both directions can run at the same time. So the service is
/// reference counted here - it starts with the first transfer, ends with the last one, and the
/// notification shows the sum of everything in flight.
class TransferNotification {
TransferNotification._();
static final _transfers = <String, _Transfer>{};
static NotificationStrings? _strings;
/// Injects the translated strings. Must be called before the first transfer starts.
/// The locale is pinned on app start, so one call is enough.
static void init(NotificationStrings strings) {
_strings = strings;
}
/// Whether [update] would reach the notification, so that callers can skip summing up the
/// progress of every file for an update that gets throttled away.
///
/// While several transfers run, they take turns passing this check, which means each of them
/// refreshes its share of the total a bit less often. The numbers stay close enough: every
/// running transfer reports progress continuously.
static bool get shouldUpdate => ForegroundService.shouldUpdateNotification;
/// Registers a transfer, starting the service if it is the first one.
///
/// Must be called while the app is in the foreground: Android 12+ rejects starting a foreground
/// service from the background.
static void start({required String sessionId, required bool receiving}) {
if (_transfers.containsKey(sessionId)) {
return;
}
final isFirst = _transfers.isEmpty;
_transfers[sessionId] = _Transfer(receiving: receiving);
if (isFirst) {
ForegroundService.start(
channelName: _requiredStrings.titleReceiving,
title: _title(),
text: _text(),
);
}
}
/// Reports the progress of a single transfer and refreshes the notification.
/// Unknown sessions are ignored, so a late progress event cannot revive a finished transfer.
static void update({
required String sessionId,
required int currentBytes,
required int totalBytes,
required int? startTime,
required int? endTime,
}) {
final transfer = _transfers[sessionId];
if (transfer == null) {
return;
}
transfer.currentBytes = currentBytes;
transfer.totalBytes = totalBytes;
transfer.speedInBytes = _speed(currentBytes: currentBytes, startTime: startTime, endTime: endTime);
ForegroundService.updateNotification(title: _title(), text: _text());
}
/// Unregisters a transfer, stopping the service once the last one is gone.
static void stop(String sessionId) {
if (_transfers.remove(sessionId) == null) {
return;
}
if (_transfers.isEmpty) {
ForegroundService.stop();
} else {
ForegroundService.updateNotification(title: _title(), text: _text());
}
}
/// The transfer speed in bytes per second, or null while it cannot be measured yet.
static int? _speed({required int currentBytes, required int? startTime, required int? endTime}) {
if (startTime == null || currentBytes < _minBytesForSpeed) {
return null;
}
final end = endTime ?? DateTime.now().millisecondsSinceEpoch;
if (end <= startTime) {
// guards the division in [getFileSpeed]
return null;
}
return getFileSpeed(start: startTime, end: end, bytes: currentBytes);
}
static NotificationStrings get _requiredStrings {
final strings = _strings;
if (strings == null) {
throw StateError('TransferNotification.init() must be called before a transfer starts');
}
return strings;
}
static String _title() {
final receiving = _transfers.values.any((transfer) => transfer.receiving);
final sending = _transfers.values.any((transfer) => !transfer.receiving);
final strings = _requiredStrings;
if (receiving && sending) {
return '${strings.titleReceiving} · ${strings.titleSending}';
}
return receiving ? strings.titleReceiving : strings.titleSending;
}
/// The combined progress of all running transfers:
/// 42% (1.2 MB / 3.4 MB)
/// 0:45 · 1.2 MB/s
static String _text() {
int currentBytes = 0;
int totalBytes = 0;
int? speedInBytes;
for (final transfer in _transfers.values) {
currentBytes += transfer.currentBytes;
totalBytes += transfer.totalBytes;
final speed = transfer.speedInBytes;
if (speed != null) {
speedInBytes = (speedInBytes ?? 0) + speed;
}
}
final percentage = totalBytes == 0 ? 0 : (100 * currentBytes / totalBytes).floor();
final text = StringBuffer('$percentage% (${currentBytes.asReadableFileSize} / ${totalBytes.asReadableFileSize})');
if (speedInBytes != null) {
text.write('\n${getRemainingTime(bytesPerSeconds: speedInBytes, remainingBytes: totalBytes - currentBytes, strings: _requiredStrings)}');
text.write(' · ${speedInBytes.asReadableFileSize}/s');
}
return text.toString();
}
}
class _Transfer {
final bool receiving;
int currentBytes = 0;
int totalBytes = 0;
int? speedInBytes;
_Transfer({required this.receiving});
}
+117
View File
@@ -161,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
@@ -182,6 +190,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_foreground_task:
dependency: "direct main"
description:
name: flutter_foreground_task
sha256: fc5c01a5e1b8f7bb51d0c737714f0c50440dbdf1aeddc5f8cbba313aa6fd4856
url: "https://pub.dev"
source: hosted
version: "9.2.2"
flutter_lints:
dependency: "direct dev"
description:
@@ -198,6 +214,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.12.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
@@ -334,6 +355,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@@ -389,6 +442,62 @@ packages:
relative: true
source: path
version: "0.0.1"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
@@ -537,6 +646,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
+1
View File
@@ -13,6 +13,7 @@ dependencies:
dart_mappable: 4.8.0
flutter:
sdk: flutter
flutter_foreground_task: 9.2.2
flutter_rust_bridge: 2.12.0
freezed_annotation: 3.1.0
gal: 2.3.2