perf: extract file status state

This commit is contained in:
Tien Do Nam
2026-08-04 00:25:12 +02:00
parent 79ac28d102
commit 90c3ad7f43
14 changed files with 204 additions and 167 deletions
+2 -7
View File
@@ -1,7 +1,6 @@
import 'package:localsend_app/provider/file_status_provider.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
import 'package:localsend_app/provider/local_ip_provider.dart';
import 'package:localsend_app/provider/logging/discovery_logs_provider.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:logging/logging.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:refena_inspector_client/refena_inspector_client.dart';
@@ -27,11 +26,7 @@ class CustomRefenaObserver extends RefenaMultiObserver {
bool _exclude(RefenaEvent event) {
return switch (event) {
ChangeEvent() =>
event.notifier is DiscoveryLogger ||
event.notifier is LocalIpService ||
event.notifier is ProgressNotifier ||
event.notifier is FileStatusNotifier,
ChangeEvent() => event.notifier is DiscoveryLogger || event.notifier is LocalIpService || event.notifier is FileTransferNotifier,
ActionDispatchedEvent() => event.action.runtimeType.toString() == '_FetchLocalIpAction',
ActionFinishedEvent() => event.action.runtimeType.toString() == '_FetchLocalIpAction',
_ => false,
+2 -5
View File
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:localsend_isolates/model/dto/file_dto.dart';
import 'package:localsend_isolates/model/file_status.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
part 'sending_file.mapper.dart';
@@ -10,17 +9,15 @@ part 'sending_file.mapper.dart';
@MappableClass()
class SendingFile with SendingFileMappable {
final FileDto file;
final FileStatus status;
final String? token;
final Uint8List? thumbnail;
final AssetEntity? asset; // for thumbnails
final String? path; // android, iOS, desktop
final List<int>? bytes; // web
final String? errorMessage; // when status == failed
final String? errorMessage; // when failed; the live status is tracked in fileTransferProvider
const SendingFile({
required this.file,
required this.status,
required this.token,
required this.thumbnail,
required this.asset,
@@ -32,6 +29,6 @@ class SendingFile with SendingFileMappable {
/// Custom toString() to avoid printing the bytes.
@override
String toString() {
return 'SendingFile(file: $file, status: $status, token: $token, thumbnail: ${thumbnail != null ? thumbnail!.length : 'null'}, asset: $asset, path: $path, bytes: ${bytes != null ? bytes!.length : 'null'}, errorMessage: $errorMessage)';
return 'SendingFile(file: $file, token: $token, thumbnail: ${thumbnail != null ? thumbnail!.length : 'null'}, asset: $asset, path: $path, bytes: ${bytes != null ? bytes!.length : 'null'}, errorMessage: $errorMessage)';
}
}
@@ -24,11 +24,6 @@ class SendingFileMapper extends ClassMapperBase<SendingFile> {
static FileDto _$file(SendingFile v) => v.file;
static const Field<SendingFile, FileDto> _f$file = Field('file', _$file);
static FileStatus _$status(SendingFile v) => v.status;
static const Field<SendingFile, FileStatus> _f$status = Field(
'status',
_$status,
);
static String? _$token(SendingFile v) => v.token;
static const Field<SendingFile, String> _f$token = Field('token', _$token);
static Uint8List? _$thumbnail(SendingFile v) => v.thumbnail;
@@ -54,7 +49,6 @@ class SendingFileMapper extends ClassMapperBase<SendingFile> {
@override
final MappableFields<SendingFile> fields = const {
#file: _f$file,
#status: _f$status,
#token: _f$token,
#thumbnail: _f$thumbnail,
#asset: _f$asset,
@@ -66,7 +60,6 @@ class SendingFileMapper extends ClassMapperBase<SendingFile> {
static SendingFile _instantiate(DecodingData data) {
return SendingFile(
file: data.dec(_f$file),
status: data.dec(_f$status),
token: data.dec(_f$token),
thumbnail: data.dec(_f$thumbnail),
asset: data.dec(_f$asset),
@@ -139,7 +132,6 @@ abstract class SendingFileCopyWith<$R, $In extends SendingFile, $Out>
ListCopyWith<$R, int, ObjectCopyWith<$R, int, int>>? get bytes;
$R call({
FileDto? file,
FileStatus? status,
String? token,
Uint8List? thumbnail,
AssetEntity? asset,
@@ -170,7 +162,6 @@ class _SendingFileCopyWithImpl<$R, $Out>
@override
$R call({
FileDto? file,
FileStatus? status,
Object? token = $none,
Object? thumbnail = $none,
Object? asset = $none,
@@ -180,7 +171,6 @@ class _SendingFileCopyWithImpl<$R, $Out>
}) => $apply(
FieldCopyWithData({
if (file != null) #file: file,
if (status != null) #status: status,
if (token != $none) #token: token,
if (thumbnail != $none) #thumbnail: thumbnail,
if (asset != $none) #asset: asset,
@@ -192,7 +182,6 @@ class _SendingFileCopyWithImpl<$R, $Out>
@override
SendingFile $make(CopyWithData data) => SendingFile(
file: data.get(#file, or: $value.file),
status: data.get(#status, or: $value.status),
token: data.get(#token, or: $value.token),
thumbnail: data.get(#thumbnail, or: $value.thumbnail),
asset: data.get(#asset, or: $value.asset),
@@ -1,22 +1,19 @@
import 'package:dart_mappable/dart_mappable.dart';
import 'package:localsend_isolates/model/dto/file_dto.dart';
import 'package:localsend_isolates/model/file_status.dart';
part 'receiving_file.mapper.dart';
@MappableClass()
class ReceivingFile with ReceivingFileMappable {
final FileDto file;
final FileStatus status;
final String? token;
final String? desiredName; // not null when accepted
final String? path; // when finished
final bool savedToGallery; // when finished
final String? errorMessage; // when status == failed
final String? errorMessage; // when failed; the live status is tracked in fileTransferProvider
const ReceivingFile({
required this.file,
required this.status,
required this.token,
required this.desiredName,
required this.path,
@@ -24,11 +24,6 @@ class ReceivingFileMapper extends ClassMapperBase<ReceivingFile> {
static FileDto _$file(ReceivingFile v) => v.file;
static const Field<ReceivingFile, FileDto> _f$file = Field('file', _$file);
static FileStatus _$status(ReceivingFile v) => v.status;
static const Field<ReceivingFile, FileStatus> _f$status = Field(
'status',
_$status,
);
static String? _$token(ReceivingFile v) => v.token;
static const Field<ReceivingFile, String> _f$token = Field('token', _$token);
static String? _$desiredName(ReceivingFile v) => v.desiredName;
@@ -52,7 +47,6 @@ class ReceivingFileMapper extends ClassMapperBase<ReceivingFile> {
@override
final MappableFields<ReceivingFile> fields = const {
#file: _f$file,
#status: _f$status,
#token: _f$token,
#desiredName: _f$desiredName,
#path: _f$path,
@@ -63,7 +57,6 @@ class ReceivingFileMapper extends ClassMapperBase<ReceivingFile> {
static ReceivingFile _instantiate(DecodingData data) {
return ReceivingFile(
file: data.dec(_f$file),
status: data.dec(_f$status),
token: data.dec(_f$token),
desiredName: data.dec(_f$desiredName),
path: data.dec(_f$path),
@@ -136,7 +129,6 @@ abstract class ReceivingFileCopyWith<$R, $In extends ReceivingFile, $Out>
implements ClassCopyWith<$R, $In, $Out> {
$R call({
FileDto? file,
FileStatus? status,
String? token,
String? desiredName,
String? path,
@@ -157,7 +149,6 @@ class _ReceivingFileCopyWithImpl<$R, $Out>
@override
$R call({
FileDto? file,
FileStatus? status,
Object? token = $none,
Object? desiredName = $none,
Object? path = $none,
@@ -166,7 +157,6 @@ class _ReceivingFileCopyWithImpl<$R, $Out>
}) => $apply(
FieldCopyWithData({
if (file != null) #file: file,
if (status != null) #status: status,
if (token != $none) #token: token,
if (desiredName != $none) #desiredName: desiredName,
if (path != $none) #path: path,
@@ -177,7 +167,6 @@ class _ReceivingFileCopyWithImpl<$R, $Out>
@override
ReceivingFile $make(CopyWithData data) => ReceivingFile(
file: data.get(#file, or: $value.file),
status: data.get(#status, or: $value.status),
token: data.get(#token, or: $value.token),
desiredName: data.get(#desiredName, or: $value.desiredName),
path: data.get(#path, or: $value.path),
+20 -20
View File
@@ -7,9 +7,9 @@ import 'package:localsend_app/config/theme.dart';
import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/state/server/receive_session_state.dart';
import 'package:localsend_app/pages/web_share_page.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
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/native/open_file.dart';
import 'package:localsend_app/util/native/open_folder.dart';
@@ -82,10 +82,8 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
// each enable() acquires a new inhibit cookie while disable() only releases one, so re-calling
// enable() every 30s leaks inhibit locks that keep the screen awake indefinitely (issue #3209).
_wakelockPlusTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
final finished =
ref.read(serverProvider)?.session?.files.values.map((e) => e.status).isFinishedOrSkipped ??
ref.read(sendProvider)[widget.sessionId]?.files.values.map((e) => e.status).isFinishedOrSkipped ??
true;
// an empty iterable (session already removed) also counts as finished
final finished = ref.read(fileTransferProvider).getStatuses(widget.sessionId).isFinishedOrSkipped;
if (finished) {
timer.cancel();
try {
@@ -97,10 +95,8 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
if (ref.read(settingsProvider).autoFinish) {
_finishTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final finished =
ref.read(serverProvider)?.session?.files.values.map((e) => e.status).isFinishedOrSkipped ??
ref.read(sendProvider)[widget.sessionId]?.files.values.map((e) => e.status).isFinishedOrSkipped ??
true;
// an empty iterable (session already removed) also counts as finished
final finished = ref.read(fileTransferProvider).getStatuses(widget.sessionId).isFinishedOrSkipped;
if (finished) {
if (_finishCounter == 1) {
timer.cancel();
@@ -118,17 +114,20 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
final receiveSession = ref.read(serverProvider)?.session;
if (receiveSession != null) {
_files = receiveSession.files.values.map((f) => f.file).toList();
// We previously used f.token != null here, but this may not work on very fast networks.
_selectedFiles = receiveSession.files.values.where((f) => f.status != FileStatus.skipped).map((f) => f.file.id).toSet();
} else {
final sendSession = ref.read(sendProvider)[widget.sessionId];
if (sendSession != null) {
_files = sendSession.files.values.map((f) => f.file).toList();
_selectedFiles = sendSession.files.values.where((f) => f.status != FileStatus.skipped).map((f) => f.file.id).toSet();
}
}
// We previously used f.token != null here, but this may not work on very fast networks.
final transferNotifier = ref.read(fileTransferProvider);
_selectedFiles = _files
.where((f) => transferNotifier.getStatus(sessionId: widget.sessionId, fileId: f.id) != FileStatus.skipped)
.map((f) => f.id)
.toSet();
_totalBytes = _files.where((f) => _selectedFiles.contains(f.id)).fold(0, (prev, curr) => prev + curr.size);
});
});
@@ -191,13 +190,15 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
@override
Widget build(BuildContext context) {
final progressNotifier = ref.watch(progressProvider);
final transferNotifier = ref.watch(fileTransferProvider);
final currBytes = _files.fold<int>(
0,
(prev, curr) => prev + ((progressNotifier.getProgress(sessionId: widget.sessionId, fileId: curr.id) * curr.size).round()),
(prev, curr) => prev + ((transferNotifier.getProgress(sessionId: widget.sessionId, fileId: curr.id) * curr.size).round()),
);
final receiveSession = ref.watch(serverProvider.select((s) => s?.session));
// No select: comparing the selected session runs the dart_mappable deep equality
// over the whole files map on every state change.
final receiveSession = ref.watch(serverProvider)?.session;
final sendSession = ref.watch(sendProvider)[widget.sessionId];
final SessionState? commonSessionState = receiveSession ?? sendSession;
@@ -235,8 +236,7 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
speedInBytes = null;
}
final fileStatusMap = receiveSession?.files.map((k, f) => MapEntry(k, f.status)) ?? sendSession!.files.map((k, f) => MapEntry(k, f.status));
final finishedCount = fileStatusMap.values.where((s) => s == FileStatus.finished).length;
final finishedCount = transferNotifier.getStatuses(widget.sessionId).where((s) => s == FileStatus.finished).length;
return PopScope(
onPopInvokedWithResult: (didPop, result) {
@@ -321,7 +321,7 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
final file = _files[index - 2];
final String fileName = receiveSession?.files[file.id]?.desiredName ?? file.fileName;
final fileStatus = fileStatusMap[file.id]!;
final fileStatus = transferNotifier.getStatus(sessionId: widget.sessionId, fileId: file.id);
final savedToGallery = receiveSession?.files[file.id]?.savedToGallery ?? false;
final String? filePath;
@@ -393,7 +393,7 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
Padding(
padding: const EdgeInsets.only(top: 5),
child: CustomProgressBar(
progress: progressNotifier.getProgress(sessionId: widget.sessionId, fileId: file.id),
progress: transferNotifier.getProgress(sessionId: widget.sessionId, fileId: file.id),
),
)
else
+4 -4
View File
@@ -6,8 +6,8 @@ import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/state/send/send_session_state.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/util/favorites.dart';
import 'package:localsend_app/util/native/taskbar_helper.dart';
import 'package:localsend_app/widget/animations/initial_fade_transition.dart';
@@ -35,7 +35,7 @@ class SendPage extends StatefulWidget {
State<SendPage> createState() => _SendPageState();
}
double _hashProgress(SendSessionState sendState, ProgressNotifier progressNotifier) {
double _hashProgress(SendSessionState sendState, FileTransferNotifier transferNotifier) {
final files = sendState.files.values;
final totalBytes = files.fold<int>(0, (prev, curr) => prev + curr.file.size);
if (totalBytes == 0) {
@@ -43,7 +43,7 @@ double _hashProgress(SendSessionState sendState, ProgressNotifier progressNotifi
}
final hashedBytes = files.fold<double>(
0,
(prev, curr) => prev + progressNotifier.getProgress(sessionId: sendState.sessionId, fileId: curr.file.id) * curr.file.size,
(prev, curr) => prev + transferNotifier.getProgress(sessionId: sendState.sessionId, fileId: curr.file.id) * curr.file.size,
);
return (hashedBytes / totalBytes).clamp(0, 1);
}
@@ -160,7 +160,7 @@ class _SendPageState extends State<SendPage> with Refena {
SizedBox(
width: 200,
child: LinearProgressIndicator(
value: _hashProgress(sendState, ref.watch(progressProvider)),
value: _hashProgress(sendState, ref.watch(fileTransferProvider)),
),
),
],
+3 -3
View File
@@ -7,10 +7,10 @@ import 'package:localsend_app/pages/selected_files_page.dart';
import 'package:localsend_app/pages/tabs/send_tab_vm.dart';
import 'package:localsend_app/pages/troubleshoot_page.dart';
import 'package:localsend_app/provider/animation_provider.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
import 'package:localsend_app/provider/network/nearby_devices_provider.dart';
import 'package:localsend_app/provider/network/scan_facade.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
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';
@@ -533,10 +533,10 @@ class _MultiSendDeviceListTile extends StatelessWidget {
final double? progress;
if (session != null) {
final files = session.files.values.where((f) => f.token != null);
final progressNotifier = ref.watch(progressProvider);
final transferNotifier = ref.watch(fileTransferProvider);
final currBytes = files.fold<int>(
0,
(prev, curr) => prev + ((progressNotifier.getProgress(sessionId: session.sessionId, fileId: curr.file.id) * curr.file.size).round()),
(prev, curr) => prev + ((transferNotifier.getProgress(sessionId: session.sessionId, fileId: curr.file.id) * curr.file.size).round()),
);
final totalBytes = files.fold<int>(0, (prev, curr) => prev + curr.file.size);
progress = totalBytes == 0 ? 0 : currBytes / totalBytes;
@@ -0,0 +1,67 @@
import 'package:localsend_isolates/model/file_status.dart';
import 'package:refena_flutter/refena_flutter.dart';
/// A provider holding the live per-file transfer state (status and progress).
/// It is implemented as [ChangeNotifier] for performance reasons:
/// a status or progress update does not need to copy the whole session state.
final fileTransferProvider = ChangeNotifierProvider((ref) => FileTransferNotifier());
class FileTransfer {
FileStatus status;
double progress; // 0..1
FileTransfer(this.status) : progress = 0;
@override
String toString() => '($status, $progress)';
}
class FileTransferNotifier extends ChangeNotifier {
final _sessionMap = <String, Map<String, FileTransfer>>{}; // session id -> (file id -> live transfer state)
void setStatus({required String sessionId, required String fileId, required FileStatus status}) {
_sessionMap.putIfAbsent(sessionId, () => {}).putIfAbsent(fileId, () => FileTransfer(status)).status = status;
notifyListeners();
}
/// Sets the status of multiple files at once, notifying listeners only once.
void setStatuses({required String sessionId, required Map<String, FileStatus> statuses}) {
final files = _sessionMap.putIfAbsent(sessionId, () => {});
for (final entry in statuses.entries) {
files.putIfAbsent(entry.key, () => FileTransfer(entry.value)).status = entry.value;
}
notifyListeners();
}
void setProgress({required String sessionId, required String fileId, required double progress}) {
_sessionMap.putIfAbsent(sessionId, () => {}).putIfAbsent(fileId, () => FileTransfer(FileStatus.queue)).progress = progress;
notifyListeners();
}
FileStatus getStatus({required String sessionId, required String fileId}) {
return _sessionMap[sessionId]?[fileId]?.status ?? FileStatus.queue;
}
Iterable<FileStatus> getStatuses(String sessionId) {
return _sessionMap[sessionId]?.values.map((file) => file.status) ?? const [];
}
double getProgress({required String sessionId, required String fileId}) {
return _sessionMap[sessionId]?[fileId]?.progress ?? 0.0;
}
void removeSession(String sessionId) {
_sessionMap.remove(sessionId);
notifyListeners();
}
void removeAllSessions() {
_sessionMap.clear();
notifyListeners();
}
/// Only for debug purposes
Map<String, Map<String, FileTransfer>> getData() {
return _sessionMap;
}
}
+64 -33
View File
@@ -9,8 +9,8 @@ import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/progress_page.dart';
import 'package:localsend_app/pages/send_page.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
import 'package:localsend_app/provider/http_provider.dart';
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/widget/dialogs/pin_dialog.dart';
@@ -62,6 +62,24 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return {};
}
/// The debug observer stringifies the state on every change,
/// so large file maps must be summarized to keep transfers responsive in debug mode.
@override
String describeState(Map<String, SendSessionState> state) {
if (state.values.every((session) => session.files.length <= 10)) {
return state.toString();
}
return state.map((sessionId, session) {
if (session.files.length <= 10) {
return MapEntry(sessionId, session.toString());
}
return MapEntry(
sessionId,
session.copyWith(files: {}).toString().replaceFirst('files: {}', 'files: <${session.files.length} files>'),
);
}).toString();
}
/// Starts a session.
/// If [background] is true, then the session closes itself on success and no pages will be open
/// If [background] is false, then this method will open pages by itself and waits for user input to close the session.
@@ -108,7 +126,6 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
)
: null,
),
status: FileStatus.queue,
token: null,
thumbnail: file.thumbnail,
asset: file.asset,
@@ -127,6 +144,13 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
),
);
ref
.notifier(fileTransferProvider)
.setStatuses(
sessionId: sessionId,
statuses: {for (final f in selectedFiles) f.id: FileStatus.queue},
);
if (!background) {
// ignore: use_build_context_synchronously, unawaited_futures
Routerino.context.push(
@@ -154,7 +178,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return;
}
ref
.notifier(progressProvider)
.notifier(fileTransferProvider)
.setProgress(
sessionId: sessionId,
fileId: id,
@@ -178,7 +202,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
// Also set for files whose hashing failed, so the progress bar stays
// consistent with the files that are left.
ref.notifier(progressProvider).setProgress(sessionId: sessionId, fileId: id, progress: 1);
ref.notifier(fileTransferProvider).setProgress(sessionId: sessionId, fileId: id, progress: 1);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(hashedFileCount: s.hashedFileCount + 1),
@@ -371,12 +395,17 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
final sendingFiles = {
for (final file in requestState.files.values)
file.file.id: fileMap.containsKey(file.file.id) ? file.copyWith(token: fileMap[file.file.id]) : file.copyWith(status: FileStatus.skipped),
file.file.id: fileMap.containsKey(file.file.id) ? file.copyWith(token: fileMap[file.file.id]) : file,
};
// The hash progress is no longer needed and must not be mistaken for
// upload progress, which starts at zero for every file.
ref.notifier(progressProvider).removeSession(sessionId);
// Recreate the transfer state: the hash progress is no longer needed and must not be
// mistaken for upload progress, which starts at zero for every file.
final transferNotifier = ref.notifier(fileTransferProvider);
transferNotifier.removeSession(sessionId);
transferNotifier.setStatuses(
sessionId: sessionId,
statuses: {for (final file in sendingFiles.values) file.file.id: file.token != null ? FileStatus.queue : FileStatus.skipped},
);
if (state[sessionId]?.background == false) {
final background = ref.read(settingsProvider).sendMode == SendMode.multiple;
@@ -422,17 +451,17 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
return;
}
final progress = ref.read(progressProvider);
final transferNotifier = ref.read(fileTransferProvider);
int currentBytes = 0;
int totalBytes = 0;
for (final sendingFile in session.files.values) {
if (sendingFile.status == FileStatus.skipped) {
if (transferNotifier.getStatus(sessionId: sessionId, fileId: sendingFile.file.id) == 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();
currentBytes += (transferNotifier.getProgress(sessionId: sessionId, fileId: sendingFile.file.id) * size).round();
}
TransferNotification.update(
@@ -470,7 +499,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
if (state[sessionId]!.status != SessionStatus.sending) {
_logger.info('Transfer was canceled.');
} else {
final hasError = sessionState.files.values.any((file) => file.status == FileStatus.failed);
final hasError = ref.read(fileTransferProvider).getStatuses(sessionId).any((status) => status == FileStatus.failed);
if (!hasError && sessionState.background == true) {
// close session because everything is fine and it is in background
closeSession(sessionId);
@@ -515,13 +544,14 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
if (isRetry) {
_logger.info('Retrying ${file.file.fileName}');
ref.notifier(fileTransferProvider).setStatus(sessionId: sessionId, fileId: file.file.id, status: FileStatus.queue);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.sending,
files: s.files.map((key, value) {
if (key == file.file.id) {
return MapEntry(key, value.copyWith(status: FileStatus.queue, errorMessage: null));
return MapEntry(key, value.copyWith(errorMessage: null));
}
return MapEntry(key, value);
}),
@@ -535,8 +565,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
);
if (isRetry) {
final state = this.state[sessionId];
if (state != null && state.files.values.map((e) => e.status).isFinishedOrError) {
if (state[sessionId] != null && ref.read(fileTransferProvider).getStatuses(sessionId).isFinishedOrError) {
_finish(sessionId: sessionId);
}
}
@@ -598,13 +627,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
switch (event) {
case HttpUploadFileStartedEvent():
_logger.info('Sending ${state[sessionId]?.files[event.fileId]?.file.fileName}');
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.withFileStatus(event.fileId, FileStatus.sending, null),
);
ref.notifier(fileTransferProvider).setStatus(sessionId: sessionId, fileId: event.fileId, status: FileStatus.sending);
case HttpUploadFileProgressEvent():
ref
.notifier(progressProvider)
.notifier(fileTransferProvider)
.setProgress(
sessionId: sessionId,
fileId: event.fileId,
@@ -614,22 +640,20 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
case HttpUploadFileFinishedEvent():
// set progress to 100% when successfully finished
ref
.notifier(progressProvider)
.notifier(fileTransferProvider)
.setProgress(
sessionId: sessionId,
fileId: event.fileId,
progress: 1,
);
_updateForegroundServiceProgress(sessionId);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.withFileStatus(event.fileId, FileStatus.finished, null),
);
ref.notifier(fileTransferProvider).setStatus(sessionId: sessionId, fileId: event.fileId, status: FileStatus.finished);
case HttpUploadFileFailedEvent():
_logger.warning('Error while sending file ${state[sessionId]?.files[event.fileId]?.file.fileName}: ${event.error}');
ref.notifier(fileTransferProvider).setStatus(sessionId: sessionId, fileId: event.fileId, status: FileStatus.failed);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.withFileStatus(event.fileId, FileStatus.failed, event.error),
state: (s) => s?.withFileError(event.fileId, event.error),
);
}
}
@@ -637,13 +661,21 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
// the whole task failed, mark all files of this task that did not finish as failed
_logger.warning('Error while sending files', e, st);
final error = e.humanErrorMessage;
final fileIds = uploadFiles.map((file) => file.fileId).toSet();
final transferNotifier = ref.notifier(fileTransferProvider);
final failedFileIds = uploadFiles
.map((file) => file.fileId)
.where((id) => const {FileStatus.queue, FileStatus.sending}.contains(transferNotifier.getStatus(sessionId: sessionId, fileId: id)))
.toSet();
transferNotifier.setStatuses(
sessionId: sessionId,
statuses: {for (final id in failedFileIds) id: FileStatus.failed},
);
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
files: s.files.map((key, value) {
if (fileIds.contains(key) && (value.status == FileStatus.queue || value.status == FileStatus.sending)) {
return MapEntry(key, value.copyWith(status: FileStatus.failed, errorMessage: error));
if (failedFileIds.contains(key)) {
return MapEntry(key, value.copyWith(errorMessage: error));
}
return MapEntry(key, value);
}),
@@ -756,7 +788,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
}
_prepareUploadCancelTokens.clear();
state = {};
ref.notifier(progressProvider).removeAllSessions();
ref.notifier(fileTransferProvider).removeAllSessions();
}
void setBackground(String sessionId, bool background) {
@@ -784,19 +816,18 @@ extension on Map<String, SendSessionState> {
}
Map<String, SendSessionState> removeSession(Ref ref, String sessionId) {
ref.notifier(progressProvider).removeSession(sessionId);
ref.notifier(fileTransferProvider).removeSession(sessionId);
return {...this}..remove(sessionId);
}
}
extension on SendSessionState {
SendSessionState withFileStatus(String fileId, FileStatus status, String? errorMessage) {
SendSessionState withFileError(String fileId, String? errorMessage) {
return copyWith(
files: {...files}
..update(
fileId,
(file) => file.copyWith(
status: status,
errorMessage: errorMessage,
),
),
@@ -11,12 +11,12 @@ import 'package:localsend_app/pages/progress_page.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/file_transfer_provider.dart';
import 'package:localsend_app/provider/http_provider.dart';
import 'package:localsend_app/provider/logging/discovery_logs_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/network/server/server_utils.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/provider/receive_history_provider.dart';
import 'package:localsend_app/provider/security_provider.dart';
import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart';
@@ -102,7 +102,6 @@ class ReceiveController {
for (final file in files.values)
file.id: ReceivingFile(
file: file,
status: FileStatus.queue,
token: null,
desiredName: null,
path: null,
@@ -120,6 +119,13 @@ class ReceiveController {
),
);
server.ref
.notifier(fileTransferProvider)
.setStatuses(
sessionId: sessionId,
statuses: {for (final file in files.values) file.id: FileStatus.queue},
);
bool quickSave = settings.quickSave && server.getState().session?.message == null;
final quickSaveFromFavorites = settings.quickSaveFromFavorites && server.getState().session?.message == null;
if (quickSaveFromFavorites) {
@@ -176,7 +182,9 @@ class ReceiveController {
}
final receiveProvider = ViewProvider((ref) {
final session = ref.watch(serverProvider.select((state) => state?.session));
// No select: comparing the selected session runs the dart_mappable deep equality
// over the whole files map on every state change.
final session = ref.watch(serverProvider)?.session;
return ReceivePageVm(
status: session?.status,
sender: session?.sender ?? Device.empty,
@@ -247,21 +255,17 @@ class ReceiveController {
}
// begin of actual file transfer
server.setState(
(oldState) => oldState?.copyWith(
session: receiveState.copyWith(
files: {...receiveState.files}
..update(
fileId,
(_) => receivingFile.copyWith(
status: FileStatus.sending,
),
),
startTime: receiveState.startTime ?? DateTime.now().millisecondsSinceEpoch,
status: SessionStatus.sending, // in case it was finishedWithErrors and user retries a failed file
server.ref.notifier(fileTransferProvider).setStatus(sessionId: event.sessionId, fileId: fileId, status: FileStatus.sending);
if (receiveState.startTime == null || receiveState.status != SessionStatus.sending) {
server.setState(
(oldState) => oldState?.copyWith(
session: receiveState.copyWith(
startTime: receiveState.startTime ?? DateTime.now().millisecondsSinceEpoch,
status: SessionStatus.sending, // in case it was finishedWithErrors and user retries a failed file
),
),
),
);
);
}
}
/// The receive progress of a file reported by the server isolate.
@@ -272,7 +276,7 @@ class ReceiveController {
}
server.ref
.notifier(progressProvider)
.notifier(fileTransferProvider)
.setProgress(
sessionId: event.sessionId,
fileId: event.fileId,
@@ -290,7 +294,7 @@ class ReceiveController {
return;
}
final progress = server.ref.read(progressProvider);
final transferNotifier = server.ref.read(fileTransferProvider);
int currentBytes = 0;
int totalBytes = 0;
for (final receivingFile in session.files.values) {
@@ -300,7 +304,7 @@ class ReceiveController {
}
final size = receivingFile.file.size;
totalBytes += size;
currentBytes += (progress.getProgress(sessionId: session.sessionId, fileId: receivingFile.file.id) * size).round();
currentBytes += (transferNotifier.getProgress(sessionId: session.sessionId, fileId: receivingFile.file.id) * size).round();
}
TransferNotification.update(
@@ -332,11 +336,11 @@ class ReceiveController {
final error = event.error;
if (error == null) {
server.ref.notifier(fileTransferProvider).setStatus(sessionId: event.sessionId, fileId: fileId, status: FileStatus.finished);
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.fileFinished(
fileId: fileId,
status: FileStatus.finished,
path: filePath,
savedToGallery: event.savedToGallery,
errorMessage: null,
@@ -361,11 +365,11 @@ class ReceiveController {
),
);
} else {
server.ref.notifier(fileTransferProvider).setStatus(sessionId: event.sessionId, fileId: fileId, status: FileStatus.failed);
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.fileFinished(
fileId: fileId,
status: FileStatus.failed,
path: null,
savedToGallery: false,
errorMessage: error,
@@ -375,7 +379,7 @@ class ReceiveController {
}
server.ref
.notifier(progressProvider)
.notifier(fileTransferProvider)
.setProgress(
sessionId: receiveState.sessionId,
fileId: fileId,
@@ -389,11 +393,12 @@ class ReceiveController {
_updateForegroundServiceProgress(session);
if (allowedStates.contains(session.status) && session.files.values.map((e) => e.status).isFinishedOrError) {
final statuses = server.ref.read(fileTransferProvider).getStatuses(session.sessionId);
if (allowedStates.contains(session.status) && statuses.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);
final hasError = statuses.any((status) => status == FileStatus.failed);
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session!.copyWith(
@@ -543,7 +548,6 @@ class ReceiveController {
entry.file.id,
ReceivingFile(
file: entry.file,
status: desiredName != null ? FileStatus.queue : FileStatus.skipped,
token: null,
desiredName: desiredName,
path: null,
@@ -558,6 +562,15 @@ class ReceiveController {
},
);
server.ref
.notifier(fileTransferProvider)
.setStatuses(
sessionId: session.sessionId,
statuses: {
for (final file in session.files.values) file.file.id: fileNameMap.containsKey(file.file.id) ? FileStatus.queue : FileStatus.skipped,
},
);
// The storage permission only exists below Android 13 (scoped storage): newer versions
// auto-deny the request, but the round trip through the system permission activity
// still blocks the UI noticeably.
@@ -678,7 +691,7 @@ class ReceiveController {
session: null,
),
);
server.ref.notifier(progressProvider).removeSession(sessionId);
server.ref.notifier(fileTransferProvider).removeSession(sessionId);
}
}
@@ -709,7 +722,6 @@ void _cancelBySender(ServerUtils server) {
extension on ReceiveSessionState {
ReceiveSessionState fileFinished({
required String fileId,
required FileStatus status,
required String? path,
required bool savedToGallery,
required String? errorMessage,
@@ -719,7 +731,6 @@ extension on ReceiveSessionState {
..update(
fileId,
(file) => file.copyWith(
status: status,
path: path,
savedToGallery: savedToGallery,
errorMessage: errorMessage,
@@ -153,7 +153,6 @@ class _InitSessionState extends ReduxAction<WebRTCReceiveService, WebRTCReceiveS
// for (final file in files)
// file.id: ReceivingFile(
// file: file,
// status: FileStatus.queue,
// token: null,
// desiredName: null,
// path: null,
-38
View File
@@ -1,38 +0,0 @@
import 'package:refena_flutter/refena_flutter.dart';
/// A provider holding the progress of the send process.
/// It is implemented as [ChangeNotifier] for performance reasons.
final progressProvider = ChangeNotifierProvider((ref) => ProgressNotifier());
class ProgressNotifier extends ChangeNotifier {
final _progressMap = <String, Map<String, double>>{}; // session id -> (file id -> 0..1)
void setProgress({required String sessionId, required String fileId, required double progress}) {
Map<String, double>? progressMap = _progressMap[sessionId];
if (progressMap == null) {
progressMap = {};
_progressMap[sessionId] = progressMap;
}
progressMap[fileId] = progress;
notifyListeners();
}
double getProgress({required String sessionId, required String fileId}) {
return _progressMap[sessionId]?[fileId] ?? 0.0;
}
void removeSession(String sessionId) {
_progressMap.remove(sessionId);
notifyListeners();
}
void removeAllSessions() {
_progressMap.clear();
notifyListeners();
}
/// Only for debug purposes
Map<String, Map<String, double>> getData() {
return _progressMap;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ class VersionData {
VersionData({
required this.version,
required this.buildNumber,
}): combinedString = '$version ($buildNumber)';
}) : combinedString = '$version ($buildNumber)';
}
final versionProvider = FutureProvider((ref) async {