diff --git a/app/lib/config/refena.dart b/app/lib/config/refena.dart index 61c0baed..7773ac4a 100644 --- a/app/lib/config/refena.dart +++ b/app/lib/config/refena.dart @@ -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, diff --git a/app/lib/model/state/send/sending_file.dart b/app/lib/model/state/send/sending_file.dart index c3c2b694..33272868 100644 --- a/app/lib/model/state/send/sending_file.dart +++ b/app/lib/model/state/send/sending_file.dart @@ -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? 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)'; } } diff --git a/app/lib/model/state/send/sending_file.mapper.dart b/app/lib/model/state/send/sending_file.mapper.dart index 4a9719af..423c3c7d 100644 --- a/app/lib/model/state/send/sending_file.mapper.dart +++ b/app/lib/model/state/send/sending_file.mapper.dart @@ -24,11 +24,6 @@ class SendingFileMapper extends ClassMapperBase { static FileDto _$file(SendingFile v) => v.file; static const Field _f$file = Field('file', _$file); - static FileStatus _$status(SendingFile v) => v.status; - static const Field _f$status = Field( - 'status', - _$status, - ); static String? _$token(SendingFile v) => v.token; static const Field _f$token = Field('token', _$token); static Uint8List? _$thumbnail(SendingFile v) => v.thumbnail; @@ -54,7 +49,6 @@ class SendingFileMapper extends ClassMapperBase { @override final MappableFields 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 { 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), diff --git a/app/lib/model/state/server/receiving_file.dart b/app/lib/model/state/server/receiving_file.dart index 3a2231b9..73831294 100644 --- a/app/lib/model/state/server/receiving_file.dart +++ b/app/lib/model/state/server/receiving_file.dart @@ -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, diff --git a/app/lib/model/state/server/receiving_file.mapper.dart b/app/lib/model/state/server/receiving_file.mapper.dart index 0aea3960..68986aff 100644 --- a/app/lib/model/state/server/receiving_file.mapper.dart +++ b/app/lib/model/state/server/receiving_file.mapper.dart @@ -24,11 +24,6 @@ class ReceivingFileMapper extends ClassMapperBase { static FileDto _$file(ReceivingFile v) => v.file; static const Field _f$file = Field('file', _$file); - static FileStatus _$status(ReceivingFile v) => v.status; - static const Field _f$status = Field( - 'status', - _$status, - ); static String? _$token(ReceivingFile v) => v.token; static const Field _f$token = Field('token', _$token); static String? _$desiredName(ReceivingFile v) => v.desiredName; @@ -52,7 +47,6 @@ class ReceivingFileMapper extends ClassMapperBase { @override final MappableFields 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 { 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), diff --git a/app/lib/pages/progress_page.dart b/app/lib/pages/progress_page.dart index 98cac548..b2abf57e 100644 --- a/app/lib/pages/progress_page.dart +++ b/app/lib/pages/progress_page.dart @@ -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 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 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 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 with Refena { @override Widget build(BuildContext context) { - final progressNotifier = ref.watch(progressProvider); + final transferNotifier = ref.watch(fileTransferProvider); final currBytes = _files.fold( 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 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 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 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 diff --git a/app/lib/pages/send_page.dart b/app/lib/pages/send_page.dart index 9af2290b..f4474b67 100644 --- a/app/lib/pages/send_page.dart +++ b/app/lib/pages/send_page.dart @@ -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 createState() => _SendPageState(); } -double _hashProgress(SendSessionState sendState, ProgressNotifier progressNotifier) { +double _hashProgress(SendSessionState sendState, FileTransferNotifier transferNotifier) { final files = sendState.files.values; final totalBytes = files.fold(0, (prev, curr) => prev + curr.file.size); if (totalBytes == 0) { @@ -43,7 +43,7 @@ double _hashProgress(SendSessionState sendState, ProgressNotifier progressNotifi } final hashedBytes = files.fold( 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 with Refena { SizedBox( width: 200, child: LinearProgressIndicator( - value: _hashProgress(sendState, ref.watch(progressProvider)), + value: _hashProgress(sendState, ref.watch(fileTransferProvider)), ), ), ], diff --git a/app/lib/pages/tabs/send_tab.dart b/app/lib/pages/tabs/send_tab.dart index b9829f89..b239ec1c 100644 --- a/app/lib/pages/tabs/send_tab.dart +++ b/app/lib/pages/tabs/send_tab.dart @@ -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( 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(0, (prev, curr) => prev + curr.file.size); progress = totalBytes == 0 ? 0 : currBytes / totalBytes; diff --git a/app/lib/provider/file_transfer_provider.dart b/app/lib/provider/file_transfer_provider.dart new file mode 100644 index 00000000..b0f24c94 --- /dev/null +++ b/app/lib/provider/file_transfer_provider.dart @@ -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 = >{}; // 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 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 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> getData() { + return _sessionMap; + } +} diff --git a/app/lib/provider/network/send_provider.dart b/app/lib/provider/network/send_provider.dart index 6aa38b5c..24ed8420 100644 --- a/app/lib/provider/network/send_provider.dart +++ b/app/lib/provider/network/send_provider.dart @@ -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> { 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 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> { ) : null, ), - status: FileStatus.queue, token: null, thumbnail: file.thumbnail, asset: file.asset, @@ -127,6 +144,13 @@ class SendNotifier extends Notifier> { ), ); + 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> { return; } ref - .notifier(progressProvider) + .notifier(fileTransferProvider) .setProgress( sessionId: sessionId, fileId: id, @@ -178,7 +202,7 @@ class SendNotifier extends Notifier> { // 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> { 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> { 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> { 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> { 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> { ); 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> { 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> { 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> { // 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> { } _prepareUploadCancelTokens.clear(); state = {}; - ref.notifier(progressProvider).removeAllSessions(); + ref.notifier(fileTransferProvider).removeAllSessions(); } void setBackground(String sessionId, bool background) { @@ -784,19 +816,18 @@ extension on Map { } Map 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, ), ), diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index f9da2a83..c5c4676d 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -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, diff --git a/app/lib/provider/network/webrtc/webrtc_receiver.dart b/app/lib/provider/network/webrtc/webrtc_receiver.dart index 1ccb1f39..9bfb46bf 100644 --- a/app/lib/provider/network/webrtc/webrtc_receiver.dart +++ b/app/lib/provider/network/webrtc/webrtc_receiver.dart @@ -153,7 +153,6 @@ class _InitSessionState extends ReduxAction ProgressNotifier()); - -class ProgressNotifier extends ChangeNotifier { - final _progressMap = >{}; // session id -> (file id -> 0..1) - - void setProgress({required String sessionId, required String fileId, required double progress}) { - Map? 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> getData() { - return _progressMap; - } -} diff --git a/app/lib/provider/version_provider.dart b/app/lib/provider/version_provider.dart index 343be75b..a33bbd5e 100644 --- a/app/lib/provider/version_provider.dart +++ b/app/lib/provider/version_provider.dart @@ -9,7 +9,7 @@ class VersionData { VersionData({ required this.version, required this.buildNumber, - }): combinedString = '$version ($buildNumber)'; + }) : combinedString = '$version ($buildNumber)'; } final versionProvider = FutureProvider((ref) async {