simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+8 -18
View File
@@ -10,9 +10,7 @@ import '../api_error.dart';
import '../http_errors.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';
@@ -65,23 +63,15 @@ AppException _dynamiteToAppException(DynamiteApiException error) {
final detail = preview.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);
if (status == 429) {
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
}
return exceptionForStatus(status, detail);
}
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
+20 -8
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import 'errors/app_exception.dart';
import 'errors/auth_exception.dart';
import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart';
@@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview';
}
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
/// [detail] as technical details: 401/403 map to auth errors, 404 to
/// not-found, everything else to a generic server error.
Never throwForStatus(int status, String detail) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
/// technical details: 401/403 map to auth errors, 404 to not-found, everything
/// else to a generic server error.
AppException exceptionForStatus(int status, String detail) => switch (status) {
401 => AuthException.unauthorized(technicalDetails: detail),
403 => AuthException.forbidden(technicalDetails: detail),
404 => NotFoundException(technicalDetails: detail),
_ => ServerException(statusCode: status, technicalDetails: detail),
};
/// Throws [exceptionForStatus].
Never throwForStatus(int status, String detail) =>
throw exceptionForStatus(status, detail);
/// Throws [exceptionForStatus] unless [response] is 2xx.
void ensureSuccess(String label, http.Response response) {
final status = response.statusCode;
if (status >= 200 && status < 300) return;
throwForStatus(status, httpErrorDetail(label, response.body, status));
}
@@ -18,13 +18,7 @@ class DeleteAppPassword {
NextcloudOcs.uri('core/apppassword'),
// An explicit header may belong to an inactive account, so the active
// session's headers must not be required then.
headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
headers: NextcloudOcs.headers(authorization: authorizationHeader),
);
}
}
@@ -28,23 +28,17 @@ class GetAppPassword {
label,
() => _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': SessionManager()
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
headers: NextcloudOcs.headers(
authorization: SessionManager()
.requireNextcloud()
.realPasswordBasicAuthHeader,
},
),
),
))!;
if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
ensureSuccess(label, response);
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null;
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
@@ -96,9 +95,8 @@ class GetUserInfo {
uri,
);
try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return CloudUserInfo(
userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '',
@@ -29,12 +29,7 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
),
))!;
if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
ensureSuccess(label, response);
return LoginFlowInit.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
@@ -53,12 +48,7 @@ class LoginFlowApi {
),
))!;
if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
ensureSuccess(label, response);
return LoginFlowCredentials.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
+5 -2
View File
@@ -13,10 +13,13 @@ class NextcloudOcs {
static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => {
/// [authorization] overrides the active session's credentials, which are
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
'Authorization':
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
};
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
/// `null`.
Future<GetChatResponse?> fetchChatPage({
required String label,
required String chatToken,
required GetChatParams params,
Duration? timeout,
bool parseOffIsolate = false,
}) async {
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded('$label $uri', () {
final request = http.get(uri, headers: headers);
return timeout == null ? request : request.timeout(timeout);
}))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final parsed = parseOffIsolate
? await compute(_parseChatResponse, response.body)
: _parseChatResponse(response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: '$label $uri: HTTP $status',
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,6 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_chat_params.dart';
@@ -16,11 +13,4 @@ class GetChat extends TalkApi<GetChatResponse> {
@override
GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,17 +1,12 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'fetch_chat_page.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// when there are no older messages left — a normal "start of chat" outcome here.
/// `setReadMarker=off` so paging into history never moves the read cursor.
/// that fetches the page of messages *older* than a given id. The server
/// answers HTTP 304 when there are no older messages left — a normal "start of
/// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into
/// history never moves the read cursor.
class GetChatHistory {
final String chatToken;
final int lastKnownMessageId;
@@ -24,39 +19,18 @@ class GetChatHistory {
});
/// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
Future<GetChatResponse?> run() => fetchChatPage(
label: 'GetChatHistory',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off,
limit: limit,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
),
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
parseOffIsolate: true,
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,15 +1,10 @@
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'fetch_chat_page.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and we need 304 to be a
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
/// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal
/// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets
/// the server move the read cursor whenever the call returns messages.
class LongPollChat {
final String chatToken;
final int lastKnownMessageId;
@@ -22,37 +17,17 @@ class LongPollChat {
});
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
Future<GetChatResponse?> run() => fetchChatPage(
label: 'LongPollChat',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 100,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
),
timeout: Duration(seconds: timeoutSeconds + 15),
);
}
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_participants_response.dart';
@@ -11,11 +9,4 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
@override
GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_poll_state_response.dart';
@@ -13,11 +11,4 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
@override
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,7 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_reactions_response.dart';
@@ -15,11 +11,4 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
@override
GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_response.dart';
@@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
@override
GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_overview_response.dart';
@@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
@override
GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -1,5 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
@@ -17,13 +16,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
@override
Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw);
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
GetRoomResponse _parseRooms(String raw) =>
+2 -1
View File
@@ -23,11 +23,12 @@ abstract class TalkApi<T extends ApiResponse?> {
TalkApi(this.path, this.body, {this.headers, this.getParameters});
/// Defaults to a plain GET; endpoints with a body or another verb override it.
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
);
) => http.get(uri, headers: headers);
T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
@@ -81,8 +81,8 @@ class MarianumConnectTokenStorage {
}
Future<void> clear() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey);
await _storage.delete(key: _expiresAtKey);
for (final key in fieldKeys) {
await _storage.delete(key: key);
}
}
}
@@ -0,0 +1,8 @@
import 'dart:io';
/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere.
String? clientPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart';
@@ -14,26 +14,20 @@ import 'get_ticker_page_response.dart';
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error. The bespoke 404 handling is why this keeps its own try/catch
/// instead of the base [guard].
/// generic error.
class GetTickerPage extends MarianumConnectQuery {
final String slug;
GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() async {
try {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<TickerPageResponse> run() => getObject(
'ticker/pages/${Uri.encodeComponent(slug)}',
TickerPageResponse.fromJson,
);
Object _mapError(DioException e) {
final response = e.response;
@override
AppException mapError(DioException error) {
final response = error.response;
if (response?.statusCode == 404) {
final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
@@ -44,7 +38,7 @@ class GetTickerPage extends MarianumConnectQuery {
);
}
}
return mapMarianumConnectError(e);
return super.mapError(error);
}
Map<String, dynamic>? _asMap(dynamic data) {
@@ -1,10 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import 'report_client_error.dart';
/// Fire-and-forget bridge from the app's global error handlers to the
@@ -61,7 +61,7 @@ class ClientErrorReporter {
message: message,
stacktrace: stacktrace,
context: context,
platform: _platform(),
platform: clientPlatform(),
appVersion: _appVersion,
);
} catch (e) {
@@ -73,10 +73,4 @@ class ClientErrorReporter {
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
return '$errorType|$normalized|${context ?? ''}';
}
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
}
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
@@ -27,7 +28,7 @@ class SubmitFeedback extends MarianumConnectQuery {
'message': message,
'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(),
'platform': clientPlatform(),
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(),
@@ -35,12 +36,6 @@ class SubmitFeedback extends MarianumConnectQuery {
);
});
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static Future<String?> _deviceModel() async {
try {
final info = DeviceInfoPlugin();