mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: foreground service on Android
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-6
@@ -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});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user