added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
+41
View File
@@ -3,11 +3,14 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart';
import '../api_error.dart';
import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart';
@@ -51,6 +54,33 @@ AppException? _dioToAppException(DioException error) {
}
}
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
/// Its toString() dumps every response header, so the details keep only the
/// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
default:
return ServerException(statusCode: status, technicalDetails: detail);
}
}
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error == null) return fallback;
if (error is AppException) return error.userMessage;
@@ -62,6 +92,10 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (mapped != null) return mapped.userMessage;
}
if (error is DynamiteApiException) {
return _dynamiteToAppException(error).userMessage;
}
if (error is SocketException) {
return const NetworkException().userMessage;
}
@@ -92,6 +126,10 @@ String? errorToTechnicalDetails(Object? error) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
}
if (error is DynamiteApiException) {
final mapped = _dynamiteToAppException(error);
return mapped.technicalDetails ?? mapped.toString();
}
return error.toString();
}
@@ -102,6 +140,9 @@ bool errorAllowsRetry(Object? error) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry;
}
if (error is DynamiteApiException) {
return _dynamiteToAppException(error).allowRetry;
}
return true;
}
+4 -1
View File
@@ -8,9 +8,12 @@ class ServerException extends AppException {
String? userMessage,
super.technicalDetails,
}) : super(
// Status code intentionally not in the user message — it lives in
// technicalDetails behind "Details anzeigen".
userMessage:
userMessage ??
'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.',
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.',
allowRetry: true,
);
}
+2 -1
View File
@@ -26,7 +26,8 @@ class TalkException extends AppException {
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
default:
if (e.code >= 500) {
return 'Talk-Server hat gerade Probleme (${e.code}).';
return 'Der Chat-Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.';
}
return e.message.isNotEmpty
? e.message
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:nextcloud/nextcloud.dart';
import '../../../../retry.dart';
import '../../webdav_api.dart';
import 'cacheable_file.dart';
import 'list_files_params.dart';
@@ -10,7 +11,11 @@ import 'list_files_response.dart';
class ListFiles extends WebdavApi<ListFilesParams> {
ListFilesParams params;
ListFiles(this.params) : super(params);
/// Forwarded to [retryOnTransientError] so the UI can surface "Erneuter
/// Versuch (2 von 3)" while a 5xx-plagued listing self-heals.
final void Function(int nextAttempt, int maxAttempts)? onRetry;
ListFiles(this.params, {this.onRetry}) : super(params);
// The Nextcloud root listing is significantly slower than subdirectories on
// our instance, so it gets a much longer ceiling. Subfolders fall back to a
@@ -44,15 +49,25 @@ class ListFiles extends WebdavApi<ListFilesParams> {
ncmounttype: true,
);
var files = await _fetch(webdav, prop, timeout);
// Our instance intermittently answers the (slow) root PROPFIND with a
// quick empty 500 — retry those transparently before surfacing an error.
var files = await retryOnTransientError(
() => _fetch(webdav, prop, timeout),
onRetry: onRetry,
);
// A freshly-entered incoming share sometimes answers its first PROPFIND
// without the OC/NC props (no fileid / has-preview / mount-type) while the
// share mount warms up server-side — which drops thumbnails AND share
// badges together. Retry a couple of times so the folder self-heals
// instead of needing manual re-entry.
// instead of needing manual re-entry. Best-effort: a failure here must
// not discard the listing that was already fetched successfully.
for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 700));
files = await _fetch(webdav, prop, timeout);
try {
files = await _fetch(webdav, prop, timeout);
} on Exception {
break;
}
}
return ListFilesResponse(files);
@@ -65,7 +80,11 @@ class ListFiles extends WebdavApi<ListFilesParams> {
) async {
final davFiles =
(await webdav
.propfind(PathUri.parse(params.path), prop: prop)
.propfind(
PathUri.parse(params.path),
prop: prop,
depth: WebDavDepth.one,
)
.timeout(timeout))
.toWebDavFiles();
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
@@ -17,9 +17,10 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
super.onError,
required String path,
super.renew = false,
void Function(int nextAttempt, int maxAttempts)? onRetry,
}) : super(
cacheTime: _cacheTimeFor(path),
loader: () => ListFiles(ListFilesParams(path)).run(),
loader: () => ListFiles(ListFilesParams(path), onRetry: onRetry).run(),
fromJson: ListFilesResponse.fromJson,
onUpdate: onUpdate,
) {
+38
View File
@@ -0,0 +1,38 @@
import 'package:nextcloud/nextcloud.dart';
/// True for errors worth a short automatic retry: HTTP 5xx thrown by the
/// nextcloud/dynamite clients. Our instance intermittently answers the slow
/// root PROPFIND with a quick empty 500 via nginx that succeeds on retry.
/// Timeouts are deliberately not transient — the root listing already has a
/// multi-minute ceiling, so repeating it would multiply the wait.
bool isTransientServerError(Object error) =>
error is DynamiteApiException &&
error.statusCode >= 500 &&
error.statusCode <= 599;
Duration _defaultRetryDelay(int retry) =>
retry == 1 ? const Duration(seconds: 1) : const Duration(seconds: 3);
/// Runs [attempt] up to [maxAttempts] times, waiting [delayFor] between
/// tries. Errors that don't match [shouldRetry] and the final failure are
/// rethrown unchanged. [onRetry] fires before each wait with the 1-based
/// number of the upcoming attempt, e.g. `(2, 3)` — used to surface retry
/// progress in the UI. [delayFor] receives the 1-based index of the retry
/// being scheduled and is injectable so tests run instantly.
Future<T> retryOnTransientError<T>(
Future<T> Function() attempt, {
int maxAttempts = 3,
Duration Function(int retry) delayFor = _defaultRetryDelay,
bool Function(Object error) shouldRetry = isTransientServerError,
void Function(int nextAttempt, int maxAttempts)? onRetry,
}) async {
for (var attemptNo = 1; ; attemptNo++) {
try {
return await attempt();
} catch (e) {
if (attemptNo >= maxAttempts || !shouldRetry(e)) rethrow;
onRetry?.call(attemptNo + 1, maxAttempts);
await Future<void>.delayed(delayFor(attemptNo));
}
}
}