mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat: add option to retry failed files (#1667)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
## 1.15.4 (unreleased)
|
||||
|
||||
- feat: add button to retry a failed file transfer (@Tienisto)
|
||||
- feat: show tooltip on the "Scan" button (@Tienisto)
|
||||
- feat: treat any URI as link, so it becomes clickable on receiver (e.g. file://, obsidian://) (@Tienisto)
|
||||
- feat(mobile): adjust padding between buttons in send tab to indicate that it's scrollable (@Tienisto)
|
||||
|
||||
@@ -68,7 +68,10 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
|
||||
|
||||
if (ref.read(settingsProvider).autoFinish) {
|
||||
_finishTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (ref.read(progressProvider).getFinishedCount(widget.sessionId) == _selectedFiles.length) {
|
||||
final finished = ref.read(serverProvider)?.session?.files.values.map((e) => e.status).isFinishedOrError ??
|
||||
ref.read(sendProvider)[widget.sessionId]?.files.values.map((e) => e.status).isFinishedOrError ??
|
||||
true;
|
||||
if (finished) {
|
||||
if (_finishCounter == 1) {
|
||||
timer.cancel();
|
||||
exit();
|
||||
@@ -194,6 +197,9 @@ 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;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
if (await _onWillPop() && mounted) {
|
||||
@@ -274,7 +280,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 = receiveSession?.files[file.id]?.status ?? sendSession!.files[file.id]!.status;
|
||||
final fileStatus = fileStatusMap[file.id]!;
|
||||
final savedToGallery = receiveSession?.files[file.id]?.savedToGallery ?? false;
|
||||
|
||||
final String? filePath;
|
||||
@@ -378,6 +384,17 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (sendSession != null && fileStatus == FileStatus.failed)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () async {
|
||||
await ref.notifier(sendProvider).sendFile(
|
||||
sessionId: widget.sessionId,
|
||||
file: sendSession.files[file.id]!,
|
||||
isRetry: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -418,7 +435,7 @@ class _ProgressPageState extends State<ProgressPage> with Refena {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.progressPage.total.count(
|
||||
curr: progressNotifier.getFinishedCount(widget.sessionId),
|
||||
curr: finishedCount,
|
||||
n: _selectedFiles.length,
|
||||
)),
|
||||
Text(t.progressPage.total.size(
|
||||
|
||||
@@ -62,7 +62,6 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
||||
required bool background,
|
||||
}) async {
|
||||
final requestDio = ref.read(dioProvider).longLiving;
|
||||
final uploadDio = ref.read(dioProvider).longLiving;
|
||||
final cancelToken = CancelToken();
|
||||
final sessionId = _uuid.v4();
|
||||
|
||||
@@ -311,125 +310,35 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
||||
),
|
||||
);
|
||||
|
||||
await _send(sessionId, uploadDio, target, sendingFiles);
|
||||
await _sendLoop(sessionId, target, sendingFiles);
|
||||
}
|
||||
|
||||
Future<void> _send(String sessionId, Dio dio, Device target, Map<String, SendingFile> files) async {
|
||||
bool hasError = false;
|
||||
final remoteSessionId = state[sessionId]!.remoteSessionId;
|
||||
|
||||
Future<void> _sendLoop(String sessionId, Device target, Map<String, SendingFile> files) async {
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.copyWith(startTime: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
final uriContent = UriContent();
|
||||
for (final file in files.values) {
|
||||
final token = file.token;
|
||||
if (token == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state[sessionId] != null && state[sessionId]!.status != SessionStatus.sending) {
|
||||
final result = await sendFile(
|
||||
sessionId: sessionId,
|
||||
file: file,
|
||||
isRetry: false,
|
||||
);
|
||||
if (!result) {
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.info('Sending ${file.file.fileName}');
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.withFileStatus(file.file.id, FileStatus.sending, null),
|
||||
);
|
||||
|
||||
final Stream<List<int>>? fileStream = file.path != null
|
||||
? file.path!.startsWith('content://')
|
||||
? uriContent.getContentStream(Uri.parse(file.path!))
|
||||
: File(file.path!).openRead()
|
||||
: null;
|
||||
|
||||
final StreamController<List<int>>? streamController;
|
||||
StreamSubscription<List<int>>? subscription;
|
||||
if (fileStream != null) {
|
||||
streamController = StreamController<List<int>>(
|
||||
onListen: () => subscription!.resume(),
|
||||
onPause: () => subscription!.pause(),
|
||||
onResume: () => subscription!.resume(),
|
||||
onCancel: () => subscription!.cancel(),
|
||||
);
|
||||
|
||||
subscription = fileStream.listen(
|
||||
(data) => streamController!.add(data),
|
||||
onError: (e, st) => streamController!.addError(e, st),
|
||||
onDone: () => streamController!.close(),
|
||||
);
|
||||
} else {
|
||||
streamController = null;
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
String? fileError;
|
||||
try {
|
||||
final cancelToken = CancelToken();
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.copyWith(cancelToken: cancelToken),
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await dio.post(
|
||||
ApiRoute.upload.target(target, query: {
|
||||
if (remoteSessionId != null) 'sessionId': remoteSessionId,
|
||||
'fileId': file.file.id,
|
||||
'token': token,
|
||||
}),
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Length': file.file.size,
|
||||
'Content-Type': file.file.lookupMime(),
|
||||
},
|
||||
),
|
||||
data: streamController?.stream ?? file.bytes!,
|
||||
onSendProgress: (curr, total) {
|
||||
if (stopwatch.elapsedMilliseconds >= 100) {
|
||||
stopwatch.reset();
|
||||
ref.notifier(progressProvider).setProgress(
|
||||
sessionId: sessionId,
|
||||
fileId: file.file.id,
|
||||
progress: curr / total,
|
||||
);
|
||||
}
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
|
||||
// set progress to 100% when successfully finished
|
||||
ref.notifier(progressProvider).setProgress(
|
||||
sessionId: sessionId,
|
||||
fileId: file.file.id,
|
||||
progress: 1,
|
||||
);
|
||||
} catch (e, st) {
|
||||
fileError = e.humanErrorMessage;
|
||||
hasError = true;
|
||||
_logger.warning('Error while sending file ${file.file.fileName}', e, st);
|
||||
} finally {
|
||||
// Close the stream if it is still open
|
||||
// ignore: unawaited_futures
|
||||
streamController?.close();
|
||||
|
||||
// Cancel the subscription if it is still open
|
||||
// ignore: unawaited_futures
|
||||
subscription?.cancel();
|
||||
}
|
||||
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.withFileStatus(file.file.id, fileError != null ? FileStatus.failed : FileStatus.finished, fileError),
|
||||
);
|
||||
}
|
||||
|
||||
_finish(sessionId: sessionId);
|
||||
}
|
||||
|
||||
void _finish({required String sessionId}) {
|
||||
if (state[sessionId] != null && state[sessionId]!.status != SessionStatus.sending) {
|
||||
_logger.info('Transfer was canceled.');
|
||||
} else {
|
||||
if (!hasError && state[sessionId]?.background == true) {
|
||||
final hasError = state[sessionId]!.files.values.any((file) => file.status == FileStatus.failed);
|
||||
if (!hasError && state[sessionId]!.background == true) {
|
||||
// close session because everything is fine and it is in background
|
||||
closeSession(sessionId);
|
||||
_logger.info('Transfer finished and session removed.');
|
||||
@@ -452,6 +361,149 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
||||
}
|
||||
}
|
||||
|
||||
final uriContent = UriContent();
|
||||
|
||||
/// Sends a file.
|
||||
/// Returns true, if the next file should be sent.
|
||||
Future<bool> sendFile({
|
||||
required String sessionId,
|
||||
required SendingFile file,
|
||||
required bool isRetry,
|
||||
}) async {
|
||||
final token = file.token;
|
||||
if (token == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final status = state[sessionId]?.status;
|
||||
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
|
||||
if (status == null || !allowedStates.contains(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final remoteSessionId = state[sessionId]!.remoteSessionId;
|
||||
final dio = ref.read(dioProvider).longLiving;
|
||||
final target = state[sessionId]!.target;
|
||||
|
||||
if (isRetry) {
|
||||
_logger.info('Retrying ${file.file.fileName}');
|
||||
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_logger.info('Sending ${file.file.fileName}');
|
||||
}
|
||||
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.withFileStatus(file.file.id, FileStatus.sending, null),
|
||||
);
|
||||
|
||||
final Stream<List<int>>? fileStream = file.path != null
|
||||
? file.path!.startsWith('content://')
|
||||
? uriContent.getContentStream(Uri.parse(file.path!))
|
||||
: File(file.path!).openRead()
|
||||
: null;
|
||||
|
||||
final StreamController<List<int>>? streamController;
|
||||
StreamSubscription<List<int>>? subscription;
|
||||
if (fileStream != null) {
|
||||
streamController = StreamController<List<int>>(
|
||||
onListen: () => subscription!.resume(),
|
||||
onPause: () => subscription!.pause(),
|
||||
onResume: () => subscription!.resume(),
|
||||
onCancel: () => subscription!.cancel(),
|
||||
);
|
||||
|
||||
subscription = fileStream.listen(
|
||||
(data) => streamController!.add(data),
|
||||
onError: (e, st) => streamController!.addError(e, st),
|
||||
onDone: () => streamController!.close(),
|
||||
);
|
||||
} else {
|
||||
streamController = null;
|
||||
subscription = null;
|
||||
}
|
||||
|
||||
String? fileError;
|
||||
try {
|
||||
final cancelToken = CancelToken();
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.copyWith(cancelToken: cancelToken),
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await dio.post(
|
||||
ApiRoute.upload.target(target, query: {
|
||||
if (remoteSessionId != null) 'sessionId': remoteSessionId,
|
||||
'fileId': file.file.id,
|
||||
'token': token,
|
||||
}),
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Length': file.file.size,
|
||||
'Content-Type': file.file.lookupMime(),
|
||||
},
|
||||
),
|
||||
data: streamController?.stream ?? file.bytes!,
|
||||
onSendProgress: (curr, total) {
|
||||
if (stopwatch.elapsedMilliseconds >= 100) {
|
||||
stopwatch.reset();
|
||||
ref.notifier(progressProvider).setProgress(
|
||||
sessionId: sessionId,
|
||||
fileId: file.file.id,
|
||||
progress: curr / total,
|
||||
);
|
||||
}
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
|
||||
// set progress to 100% when successfully finished
|
||||
ref.notifier(progressProvider).setProgress(
|
||||
sessionId: sessionId,
|
||||
fileId: file.file.id,
|
||||
progress: 1,
|
||||
);
|
||||
} catch (e, st) {
|
||||
fileError = e.humanErrorMessage;
|
||||
_logger.warning('Error while sending file ${file.file.fileName}', e, st);
|
||||
} finally {
|
||||
// Close the stream if it is still open
|
||||
// ignore: unawaited_futures
|
||||
streamController?.close();
|
||||
|
||||
// Cancel the subscription if it is still open
|
||||
// ignore: unawaited_futures
|
||||
subscription?.cancel();
|
||||
}
|
||||
|
||||
state = state.updateSession(
|
||||
sessionId: sessionId,
|
||||
state: (s) => s?.withFileStatus(file.file.id, fileError != null ? FileStatus.failed : FileStatus.finished, fileError),
|
||||
);
|
||||
|
||||
if (isRetry) {
|
||||
final state = this.state[sessionId];
|
||||
if (state != null && state.files.values.map((e) => e.status).isFinishedOrError) {
|
||||
_finish(sessionId: sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Closes the send-session and sends a cancel event to the receiver.
|
||||
void cancelSession(String sessionId) {
|
||||
final sessionState = state[sessionId];
|
||||
|
||||
@@ -390,8 +390,9 @@ class ReceiveController {
|
||||
return server.responseJson(403, message: 'Invalid IP address: ${request.ip}');
|
||||
}
|
||||
|
||||
if (receiveState.status != SessionStatus.sending) {
|
||||
_logger.warning('Wrong state: ${receiveState.status} (expected: ${SessionStatus.sending})');
|
||||
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
|
||||
if (!allowedStates.contains(receiveState.status)) {
|
||||
_logger.warning('Wrong state: ${receiveState.status}');
|
||||
return server.responseJson(409, message: 'Recipient is in wrong state');
|
||||
}
|
||||
|
||||
@@ -425,10 +426,10 @@ class ReceiveController {
|
||||
fileId,
|
||||
(_) => receivingFile.copyWith(
|
||||
status: FileStatus.sending,
|
||||
token: null, // remove token to reject further uploads of the same file
|
||||
),
|
||||
),
|
||||
startTime: receiveState.startTime ?? DateTime.now().millisecondsSinceEpoch,
|
||||
status: SessionStatus.sending, // in case it was finishedWithErrors and user retries a failed file
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -465,7 +466,7 @@ class ReceiveController {
|
||||
}
|
||||
},
|
||||
);
|
||||
if (server.getState().session == null || server.getState().session!.status != SessionStatus.sending) {
|
||||
if (server.getState().session == null || !allowedStates.contains(server.getState().session!.status)) {
|
||||
return server.responseJson(500, message: 'Server is in invalid state');
|
||||
}
|
||||
server.setState(
|
||||
@@ -516,8 +517,7 @@ class ReceiveController {
|
||||
);
|
||||
|
||||
final session = server.getState().session!;
|
||||
if (session.status == SessionStatus.sending &&
|
||||
session.files.values.every((f) => f.status == FileStatus.finished || f.status == FileStatus.skipped || f.status == FileStatus.failed)) {
|
||||
if (allowedStates.contains(session.status) && session.files.values.map((e) => e.status).isFinishedOrError) {
|
||||
final hasError = session.files.values.any((f) => f.status == FileStatus.failed);
|
||||
server.setState(
|
||||
(oldState) => oldState?.copyWith(
|
||||
@@ -540,7 +540,7 @@ class ReceiveController {
|
||||
|
||||
return server.getState().session?.files[fileId]?.status == FileStatus.finished
|
||||
? server.responseJson(200)
|
||||
: server.responseJson(500, message: 'Could not save file');
|
||||
: server.responseJson(500, message: 'Could not save file. Check receiving device for more information.');
|
||||
}
|
||||
|
||||
Response _cancelHandler({
|
||||
|
||||
@@ -21,14 +21,6 @@ class ProgressNotifier extends ChangeNotifier {
|
||||
return _progressMap[sessionId]?[fileId] ?? 0.0;
|
||||
}
|
||||
|
||||
int getFinishedCount(String sessionId) {
|
||||
final progressMap = _progressMap[sessionId];
|
||||
if (progressMap == null) {
|
||||
return 0;
|
||||
}
|
||||
return progressMap.values.fold(0, (prev, curr) => curr == 1 ? prev + 1 : prev);
|
||||
}
|
||||
|
||||
void removeSession(String sessionId) {
|
||||
_progressMap.remove(sessionId);
|
||||
notifyListeners();
|
||||
|
||||
@@ -7,3 +7,7 @@ enum FileStatus {
|
||||
failed,
|
||||
finished,
|
||||
}
|
||||
|
||||
extension FileStatusIterable on Iterable<FileStatus> {
|
||||
bool get isFinishedOrError => every((status) => const {FileStatus.skipped, FileStatus.failed, FileStatus.finished}.contains(status));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user