feat: add option to retry failed files (#1667)

This commit is contained in:
Tien Do Nam
2024-08-19 23:11:15 +02:00
committed by GitHub
parent 33adde4215
commit 109cb4f066
6 changed files with 189 additions and 123 deletions
+1
View File
@@ -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)
+20 -3
View File
@@ -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(
+157 -105
View File
@@ -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({
-8
View File
@@ -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();
+4
View File
@@ -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));
}