added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user