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