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
@@ -15,6 +15,10 @@
<uses-permission android:name="android.permission.BIND_QUICK_SETTINGS_TILE" tools:targetApi="24" />
<!-- localsend: keep the app process alive while files are being received.
FOREGROUND_SERVICE and POST_NOTIFICATIONS come from the flutter_foreground_task manifest. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
@@ -81,6 +85,13 @@
</intent-filter>
</service>
<!-- Foreground service showing the receive progress.
Warning: the name is defined by flutter_foreground_task and must not be changed. -->
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
+4 -1
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:flutter/foundation.dart';
@@ -35,6 +34,7 @@ import 'package:localsend_app/util/native/device_info_helper.dart';
import 'package:localsend_app/util/native/macos_channel.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/util/native/tray_helper.dart';
import 'package:localsend_app/util/notification_strings.dart';
import 'package:localsend_app/util/ui/dynamic_colors.dart';
import 'package:localsend_app/util/ui/snackbar.dart';
import 'package:localsend_isolates/api_route_builder.dart';
@@ -45,6 +45,7 @@ import 'package:localsend_isolates/model/dto/multicast_dto.dart';
import 'package:localsend_isolates/rust/api/logging.dart' as rust_logging;
import 'package:localsend_isolates/rust/frb_generated.dart';
import 'package:localsend_isolates/util/logger.dart';
import 'package:localsend_isolates/util/transfer_notification.dart';
import 'package:logging/logging.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:share_handler/share_handler.dart';
@@ -81,6 +82,8 @@ Future<RefenaContainer> preInit(List<String> args) async {
await initI18n();
TransferNotification.init(notificationStrings);
bool startHidden = false;
if (checkPlatformIsDesktop()) {
// Check if this app is already open and let it "show up".
+1 -1
View File
@@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/provider/apk_provider.dart';
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/native/cross_file_converters.dart';
import 'package:localsend_app/util/ui/nav_bar_padding.dart';
import 'package:localsend_app/widget/file_thumbnail.dart';
import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_app/widget/sliver/sliver_pinned_header.dart';
import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
+4 -3
View File
@@ -11,12 +11,11 @@ import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/file_speed_helper.dart';
import 'package:localsend_app/util/native/open_file.dart';
import 'package:localsend_app/util/native/open_folder.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/util/native/taskbar_helper.dart';
import 'package:localsend_app/util/notification_strings.dart';
import 'package:localsend_app/util/ui/nav_bar_padding.dart';
import 'package:localsend_app/widget/custom_basic_appbar.dart';
import 'package:localsend_app/widget/custom_progress_bar.dart';
@@ -26,6 +25,8 @@ import 'package:localsend_app/widget/file_thumbnail.dart';
import 'package:localsend_isolates/model/dto/file_dto.dart';
import 'package:localsend_isolates/model/file_status.dart';
import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:localsend_isolates/util/file_speed_helper.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -217,7 +218,7 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastRemainingTimeUpdate >= 1000) {
_remainingTime = getRemainingTime(bytesPerSeconds: speedInBytes, remainingBytes: _totalBytes - currBytes);
_remainingTime = getRemainingTime(bytesPerSeconds: speedInBytes, remainingBytes: _totalBytes - currBytes, strings: notificationStrings);
_lastRemainingTimeUpdate = now;
}
} else {
+1 -1
View File
@@ -7,7 +7,6 @@ import 'package:localsend_app/model/persistence/receive_history_entry.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/provider/receive_history_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/native/directories.dart';
import 'package:localsend_app/util/native/open_file.dart';
import 'package:localsend_app/util/native/open_folder.dart';
@@ -19,6 +18,7 @@ import 'package:localsend_app/widget/file_thumbnail.dart';
import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:path/path.dart' as path;
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
+1 -1
View File
@@ -3,7 +3,6 @@ import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/file_type_ext.dart';
import 'package:localsend_app/util/native/pick_directory_path.dart';
import 'package:localsend_app/util/native/platform_check.dart';
@@ -12,6 +11,7 @@ import 'package:localsend_app/widget/custom_icon_button.dart';
import 'package:localsend_app/widget/dialogs/file_name_input_dialog.dart';
import 'package:localsend_app/widget/dialogs/quick_actions_dialog.dart';
import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:refena_flutter/refena_flutter.dart';
class ReceiveOptionsPage extends StatelessWidget {
+1 -1
View File
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/native/open_file.dart';
import 'package:localsend_app/util/ui/nav_bar_padding.dart';
import 'package:localsend_app/widget/custom_basic_appbar.dart';
@@ -11,6 +10,7 @@ import 'package:localsend_app/widget/dialogs/message_input_dialog.dart';
import 'package:localsend_app/widget/file_thumbnail.dart';
import 'package:localsend_app/widget/responsive_list_view.dart';
import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
+1 -1
View File
@@ -15,7 +15,6 @@ import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/favorites.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_app/util/native/file_picker.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/widget/big_button.dart';
@@ -32,6 +31,7 @@ import 'package:localsend_app/widget/responsive_wrap_view.dart';
import 'package:localsend_app/widget/rotating_widget.dart';
import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
+50 -1
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localsend_app/model/cross_file.dart';
import 'package:localsend_app/model/send_mode.dart';
@@ -25,6 +24,7 @@ import 'package:localsend_isolates/rust/api/http.dart' as rust_http;
import 'package:localsend_isolates/rust/api/model.dart' as rust_model;
import 'package:localsend_isolates/util/rust.dart';
import 'package:localsend_isolates/util/sleep.dart';
import 'package:localsend_isolates/util/transfer_notification.dart';
import 'package:logging/logging.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
@@ -306,9 +306,48 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
),
);
// Keep the process alive for the whole transfer. Started here, while the app is still in the
// foreground, because Android 12+ rejects starting a foreground service from the background.
TransferNotification.start(sessionId: sessionId, receiving: false);
await _sendLoop(sessionId, sendingFiles);
}
/// Reports the total session progress to the foreground service notification,
/// so that it stays up to date while the app is minimized.
void _updateForegroundServiceProgress(String sessionId) {
if (!TransferNotification.shouldUpdate) {
// Checked before the sum below because progress events arrive several times per second per file.
return;
}
final session = state[sessionId];
if (session == null) {
return;
}
final progress = ref.read(progressProvider);
int currentBytes = 0;
int totalBytes = 0;
for (final sendingFile in session.files.values) {
if (sendingFile.status == FileStatus.skipped) {
// not accepted by the receiver
continue;
}
final size = sendingFile.file.size;
totalBytes += size;
currentBytes += (progress.getProgress(sessionId: sessionId, fileId: sendingFile.file.id) * size).round();
}
TransferNotification.update(
sessionId: sessionId,
currentBytes: currentBytes,
totalBytes: totalBytes,
startTime: session.startTime,
endTime: session.endTime,
);
}
Future<void> _sendLoop(String sessionId, Map<String, SendingFile> files) async {
state = state.updateSession(
sessionId: sessionId,
@@ -329,6 +368,9 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return;
}
// The transfer is over, the process no longer needs to be kept alive for it.
TransferNotification.stop(sessionId);
if (state[sessionId]!.status != SessionStatus.sending) {
_logger.info('Transfer was canceled.');
} else {
@@ -472,6 +514,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
fileId: event.fileId,
progress: event.progress,
);
_updateForegroundServiceProgress(sessionId);
case HttpUploadFileFinishedEvent():
// set progress to 100% when successfully finished
ref
@@ -481,6 +524,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
fileId: event.fileId,
progress: 1,
);
_updateForegroundServiceProgress(sessionId);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.withFileStatus(event.fileId, FileStatus.finished, null),
@@ -560,6 +604,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
if (sessionState == null) {
return;
}
TransferNotification.stop(sessionId);
_cancelRunningRequests(sessionState);
state = state.updateSession(
@@ -589,6 +634,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
if (sessionState == null) {
return;
}
TransferNotification.stop(sessionId);
state = state.removeSession(ref, sessionId);
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
// clear selected files
@@ -597,6 +643,9 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
}
void clearAllSessions() {
for (final sessionId in state.keys) {
TransferNotification.stop(sessionId);
}
state = {};
ref.notifier(progressProvider).removeAllSessions();
}
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -35,6 +34,7 @@ import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/rust/api/server.dart' show SessionEndReasonV2;
import 'package:localsend_isolates/util/rust.dart';
import 'package:localsend_isolates/util/transfer_notification.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:refena_flutter/refena_flutter.dart';
@@ -269,6 +269,38 @@ class ReceiveController {
fileId: event.fileId,
progress: event.progress,
);
_updateForegroundServiceProgress(receiveState);
}
/// Reports the total session progress to the foreground service notification,
/// so that it stays up to date while the app is minimized.
void _updateForegroundServiceProgress(ReceiveSessionState session) {
if (!TransferNotification.shouldUpdate) {
// Checked before the sum below because progress events arrive several times per second per file.
return;
}
final progress = server.ref.read(progressProvider);
int currentBytes = 0;
int totalBytes = 0;
for (final receivingFile in session.files.values) {
if (receivingFile.desiredName == null) {
// not accepted by the user
continue;
}
final size = receivingFile.file.size;
totalBytes += size;
currentBytes += (progress.getProgress(sessionId: session.sessionId, fileId: receivingFile.file.id) * size).round();
}
TransferNotification.update(
sessionId: session.sessionId,
currentBytes: currentBytes,
totalBytes: totalBytes,
startTime: session.startTime,
endTime: session.endTime,
);
}
/// A file has been received completely (or failed) by the server isolate.
@@ -346,7 +378,12 @@ class ReceiveController {
return;
}
_updateForegroundServiceProgress(session);
if (allowedStates.contains(session.status) && session.files.values.map((e) => e.status).isFinishedOrError) {
// The transfer is over, the process no longer needs to be kept alive for it.
TransferNotification.stop(session.sessionId);
final hasError = session.files.values.any((f) => f.status == FileStatus.failed);
server.setState(
(oldState) => oldState?.copyWith(
@@ -526,6 +563,13 @@ class ReceiveController {
}
}
// Keep the process alive for the whole transfer. Started here because:
// - the app is still in the foreground, and Android 12+ rejects starting a foreground service
// from the background,
// - the service may ask for the notification permission, which Android cancels when it overlaps
// with the storage permission requests above.
TransferNotification.start(sessionId: session.sessionId, receiving: true);
// From here on, the server isolate receives all accepted files on its own
// and reports back via upload progress/result events.
final updatedSession = server.getStateOrNull()?.session;
@@ -619,6 +663,8 @@ class ReceiveController {
return;
}
TransferNotification.stop(sessionId);
server.setState(
(oldState) => oldState?.copyWith(
session: null,
@@ -634,6 +680,8 @@ void _cancelBySender(ServerUtils server) {
return;
}
TransferNotification.stop(receiveSession.sessionId);
if (receiveSession.status == SessionStatus.waiting) {
// received cancel during accept/decline
// pop just in case if user is in [ReceiveOptionsPage]
+15
View File
@@ -0,0 +1,15 @@
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_isolates/util/notification_strings.dart';
/// The translations that `localsend_isolates` needs, taken from the current locale.
///
/// Lazy, so it resolves after the locale has been applied in `preInit`. The locale is pinned on
/// app start, so binding the translation members once is enough.
final notificationStrings = NotificationStrings(
titleReceiving: t.progressPage.titleReceiving,
titleSending: t.progressPage.titleSending,
remainingTimeSeconds: t.progressPage.remainingTime.seconds,
remainingTimeMinutes: t.progressPage.remainingTime.minutes,
remainingTimeHours: t.progressPage.remainingTime.hours,
remainingTimeDays: t.progressPage.remainingTime.days,
);
+1 -1
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/persistence/receive_history_entry.dart';
import 'package:localsend_app/util/file_size_helper.dart';
import 'package:localsend_isolates/util/file_size_helper.dart';
import 'package:routerino/routerino.dart';
class FileInfoDialog extends StatelessWidget {
+8
View File
@@ -519,6 +519,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_foreground_task:
dependency: transitive
description:
name: flutter_foreground_task
sha256: fc5c01a5e1b8f7bb51d0c737714f0c50440dbdf1aeddc5f8cbba313aa6fd4856
url: "https://pub.dev"
source: hosted
version: "9.2.2"
flutter_gen_core:
dependency: transitive
description:
+24 -23
View File
@@ -1,49 +1,50 @@
import 'package:localsend_app/util/file_speed_helper.dart';
import 'package:localsend_app/util/notification_strings.dart';
import 'package:localsend_isolates/util/file_speed_helper.dart';
import 'package:test/test.dart';
void main() {
group('getRemainingTime', () {
test('shows seconds for duration less than 1 minute', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 30000), '0:30');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 45000), '0:45');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 5000), '0:05');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 30000, strings: notificationStrings), '0:30');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 45000, strings: notificationStrings), '0:45');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 5000, strings: notificationStrings), '0:05');
});
test('shows minutes and seconds for duration less than 1 hour', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 90000), '1:30');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 600000), '10:00');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3540000), '59:00');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 90000, strings: notificationStrings), '1:30');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 600000, strings: notificationStrings), '10:00');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3540000, strings: notificationStrings), '59:00');
});
test('shows hours and minutes for duration 1 hour or more but less than 1 day', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3600000), '1h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3660000), '1h 1m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 7200000), '2h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 12000000), '3h 20m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 86340000), '23h 59m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3600000, strings: notificationStrings), '1h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 3660000, strings: notificationStrings), '1h 1m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 7200000, strings: notificationStrings), '2h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 12000000, strings: notificationStrings), '3h 20m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 86340000, strings: notificationStrings), '23h 59m');
});
test('shows days, hours and minutes for duration 1 day or more', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 86400000), '1d 0h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 90000000), '1d 1h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 93660000), '1d 2h 1m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 172800000), '2d 0h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 183780000), '2d 3h 3m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 86400000, strings: notificationStrings), '1d 0h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 90000000, strings: notificationStrings), '1d 1h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 93660000, strings: notificationStrings), '1d 2h 1m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 172800000, strings: notificationStrings), '2d 0h 0m');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 183780000, strings: notificationStrings), '2d 3h 3m');
});
test('handles zero remaining bytes', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 0), '0:00');
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 0), '0:00');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 0, strings: notificationStrings), '0:00');
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 0, strings: notificationStrings), '0:00');
});
test('handles zero speed with remaining bytes', () {
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 1000), '');
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 1000000), '');
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 1000, strings: notificationStrings), '');
expect(getRemainingTime(bytesPerSeconds: 0, remainingBytes: 1000000, strings: notificationStrings), '');
});
test('handles edge cases', () {
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 1000), '0:01');
expect(getRemainingTime(bytesPerSeconds: 1, remainingBytes: 1), '0:01');
expect(getRemainingTime(bytesPerSeconds: 1000, remainingBytes: 1000, strings: notificationStrings), '0:01');
expect(getRemainingTime(bytesPerSeconds: 1, remainingBytes: 1, strings: notificationStrings), '0:01');
});
});
@@ -1,4 +1,4 @@
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_isolates/util/notification_strings.dart';
const _millisecondsPerSecond = 1000;
const _secondsPerMinute = 60;
@@ -17,29 +17,30 @@ int getFileSpeed({
String getRemainingTime({
required int bytesPerSeconds,
required int remainingBytes,
required NotificationStrings strings,
}) {
if (bytesPerSeconds == 0) {
return remainingBytes == 0 ? t.progressPage.remainingTime.seconds(n: 0, ss: '00') : '';
return remainingBytes == 0 ? strings.remainingTimeSeconds(n: 0, ss: '00') : '';
}
final remainingTimeInSeconds = _getRemainingTime(bytesPerSeconds: bytesPerSeconds, remainingBytes: remainingBytes);
if (remainingTimeInSeconds < _secondsPerMinute) {
return t.progressPage.remainingTime.seconds(n: 0, ss: remainingTimeInSeconds.toString().padLeft(2, '0'));
return strings.remainingTimeSeconds(n: 0, ss: remainingTimeInSeconds.toString().padLeft(2, '0'));
} else if (remainingTimeInSeconds < _secondsPerHour) {
final minutes = remainingTimeInSeconds ~/ _secondsPerMinute;
final seconds = remainingTimeInSeconds % _secondsPerMinute;
return t.progressPage.remainingTime.minutes(n: minutes, ss: seconds.toString().padLeft(2, '0'));
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 t.progressPage.remainingTime.hours(h: hours, m: minutes);
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 t.progressPage.remainingTime.days(d: days, h: hours, m: minutes);
return strings.remainingTimeDays(d: days, h: hours, m: minutes);
}
}
@@ -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