From 2326c9d1d6c409963cef36132dc4cdea3ea32002 Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Fri, 30 Dec 2022 01:28:43 +0100 Subject: [PATCH] Fixed login and error handling --- .../authentication.interceptor.dart | 38 ----- .../interceptor/base_url_interceptor.dart | 31 ---- .../dio_http_error_interceptor.dart | 74 ++++++--- .../language_header.interceptor.dart | 31 +--- .../response_conversion.interceptor.dart | 38 ----- ...etry_on_connection_change_interceptor.dart | 71 ++++++++ .../provider/label_repositories_provider.dart | 2 +- .../authentication_aware_dio_manager.dart | 60 ++++--- lib/core/service/dio_file_service.dart | 82 +++++++++ lib/di_test_mocks.dart | 69 -------- .../view/pages/document_details_page.dart | 4 - .../cubit/document_upload_cubit.dart | 4 - .../document_upload_preparation_page.dart | 4 +- .../documents/bloc/documents_cubit.dart | 4 +- .../documents/bloc/documents_state.dart | 7 +- .../documents/view/pages/documents_page.dart | 3 + .../view/widgets/document_preview.dart | 22 ++- .../view/widgets/documents_empty_state.dart | 1 + .../view/widgets/grid/document_grid.dart | 1 + .../view/widgets/list/document_list.dart | 1 + .../bulk_delete_confirmation_dialog.dart | 1 + .../selection/documents_page_app_bar.dart | 1 + .../view/widgets/sort_documents_button.dart | 1 + lib/features/edit_label/view/label_form.dart | 5 +- .../inbox/view/widgets/inbox_item.dart | 1 + .../login/bloc/authentication_cubit.dart | 102 +++++++----- .../login/bloc/authentication_state.dart | 8 +- .../login/bloc/authentication_state.g.dart | 6 - lib/features/login/view/login_page.dart | 2 +- .../widgets/server_address_form_field.dart | 5 + .../view/saved_view_selection_widget.dart | 1 + .../bloc/application_settings_cubit.dart | 20 ++- .../biometric_authentication_setting.dart | 11 +- lib/main.dart | 157 ++++++++---------- .../id_query_parameter_json_converter.dart | 21 +++ .../lib/src/models/document_filter.dart | 2 + .../lib/src/models/paged_search_result.dart | 3 + .../query_parameters/id_query_parameter.dart | 11 ++ .../authentication_api_impl.dart | 23 +-- .../paperless_documents_api_impl.dart | 10 +- pubspec.lock | 8 + pubspec.yaml | 1 + test/src/bloc/document_cubit_test.dart | 2 + 43 files changed, 502 insertions(+), 447 deletions(-) delete mode 100644 lib/core/interceptor/authentication.interceptor.dart delete mode 100644 lib/core/interceptor/base_url_interceptor.dart delete mode 100644 lib/core/interceptor/response_conversion.interceptor.dart create mode 100644 lib/core/interceptor/retry_on_connection_change_interceptor.dart create mode 100644 lib/core/service/dio_file_service.dart delete mode 100644 lib/di_test_mocks.dart create mode 100644 packages/paperless_api/lib/src/converters/id_query_parameter_json_converter.dart diff --git a/lib/core/interceptor/authentication.interceptor.dart b/lib/core/interceptor/authentication.interceptor.dart deleted file mode 100644 index c89bb61..0000000 --- a/lib/core/interceptor/authentication.interceptor.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; -import 'package:http_interceptor/http_interceptor.dart'; - -class AuthenticationInterceptor implements InterceptorContract { - String? serverUrl; - String? token; - AuthenticationInterceptor({this.serverUrl, this.token}); - - @override - Future interceptRequest({required BaseRequest request}) async { - if (kDebugMode) { - log("Intercepted ${request.method} request to ${request.url.toString()}"); - } - - return request.copyWith( - url: Uri.parse((serverUrl ?? '') + request.url.toString()), - headers: token?.isEmpty ?? true - ? request.headers - : { - ...request.headers, - 'Authorization': 'Token $token', - }, - ); - } - - @override - Future interceptResponse( - {required BaseResponse response}) async => - response; - - @override - Future shouldInterceptRequest() async => true; - - @override - Future shouldInterceptResponse() async => true; -} diff --git a/lib/core/interceptor/base_url_interceptor.dart b/lib/core/interceptor/base_url_interceptor.dart deleted file mode 100644 index 065a8eb..0000000 --- a/lib/core/interceptor/base_url_interceptor.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; - -class BaseUrlInterceptor implements InterceptorContract { - final LocalVault _localVault; - - BaseUrlInterceptor(this._localVault); - @override - Future interceptRequest({required BaseRequest request}) async { - final auth = await _localVault.loadAuthenticationInformation(); - if (auth == null) { - throw Exception( - "Authentication information not available, cannot perform request!", - ); - } - return request.copyWith( - url: Uri.parse(auth.serverUrl + request.url.toString()), - ); - } - - @override - Future interceptResponse( - {required BaseResponse response}) async => - response; - - @override - Future shouldInterceptRequest() async => true; - - @override - Future shouldInterceptResponse() async => true; -} diff --git a/lib/core/interceptor/dio_http_error_interceptor.dart b/lib/core/interceptor/dio_http_error_interceptor.dart index 81b7b8c..92e83d6 100644 --- a/lib/core/interceptor/dio_http_error_interceptor.dart +++ b/lib/core/interceptor/dio_http_error_interceptor.dart @@ -1,38 +1,62 @@ -import 'dart:convert'; - import 'package:dio/dio.dart'; +import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/type/types.dart'; -class DioHttpErrorInterceptor implements InterceptorsWrapper { +class DioHttpErrorInterceptor extends Interceptor { @override - void onError(DioError e, ErrorInterceptorHandler handler) { - //TODO: Implement and debug how error handling works, or if request has to be resolved. - if (e.response?.statusCode == 400) { + void onError(DioError err, ErrorInterceptorHandler handler) { + if (err.response?.statusCode == 400) { // try to parse contained error message, otherwise return response - final Map json = jsonDecode(e.response?.data); - final PaperlessValidationErrors errorMessages = {}; - for (final entry in json.entries) { - if (entry.value is List) { - errorMessages.putIfAbsent( - entry.key, () => (entry.value as List).cast().first); - } else if (entry.value is String) { - errorMessages.putIfAbsent(entry.key, () => entry.value); - } else { - errorMessages.putIfAbsent(entry.key, () => entry.value.toString()); - } + final dynamic data = err.response?.data; + if (data is Map) { + return _handlePaperlessValidationError(data, handler, err); + } else if (data is String) { + return _handlePlainError(data, handler, err); } - throw errorMessages; } - handler.next(e); + handler.reject(err); } - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - handler.next(options); + void _handlePaperlessValidationError( + Map json, + ErrorInterceptorHandler handler, + DioError err, + ) { + final PaperlessValidationErrors errorMessages = {}; + for (final entry in json.entries) { + if (entry.value is List) { + errorMessages.putIfAbsent( + entry.key, + () => (entry.value as List).cast().first, + ); + } else if (entry.value is String) { + errorMessages.putIfAbsent(entry.key, () => entry.value); + } else { + errorMessages.putIfAbsent(entry.key, () => entry.value.toString()); + } + } + return handler.reject( + DioError( + error: errorMessages, + requestOptions: err.requestOptions, + type: DioErrorType.response, + ), + ); } - @override - void onResponse(Response response, ResponseInterceptorHandler handler) { - handler.next(response); + void _handlePlainError( + String data, + ErrorInterceptorHandler handler, + DioError err, + ) { + if (data.contains("No required SSL certificate was sent")) { + handler.reject( + DioError( + requestOptions: err.requestOptions, + type: DioErrorType.response, + error: ErrorCode.missingClientCertificate, + ), + ); + } } } diff --git a/lib/core/interceptor/language_header.interceptor.dart b/lib/core/interceptor/language_header.interceptor.dart index cce34b0..1261676 100644 --- a/lib/core/interceptor/language_header.interceptor.dart +++ b/lib/core/interceptor/language_header.interceptor.dart @@ -1,32 +1,19 @@ +import 'package:dio/dio.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -import 'package:http_interceptor/http_interceptor.dart'; -class LanguageHeaderInterceptor implements InterceptorContract { - final ApplicationSettingsCubit appSettingsCubit; - - LanguageHeaderInterceptor(this.appSettingsCubit); +class LanguageHeaderInterceptor extends Interceptor { + String preferredLocaleSubtag; + LanguageHeaderInterceptor(this.preferredLocaleSubtag); @override - Future interceptRequest({required BaseRequest request}) async { + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { late String languages; - if (appSettingsCubit.state.preferredLocaleSubtag == "en") { + if (preferredLocaleSubtag == "en") { languages = "en"; } else { - languages = appSettingsCubit.state.preferredLocaleSubtag + - ",en;q=0.7,en-US;q=0.6"; + languages = "$preferredLocaleSubtag,en;q=0.7,en-US;q=0.6"; } - request.headers.addAll({"Accept-Language": languages}); - return request; + options.headers.addAll({"Accept-Language": languages}); + handler.next(options); } - - @override - Future interceptResponse( - {required BaseResponse response}) async => - response; - - @override - Future shouldInterceptRequest() async => true; - - @override - Future shouldInterceptResponse() async => true; } diff --git a/lib/core/interceptor/response_conversion.interceptor.dart b/lib/core/interceptor/response_conversion.interceptor.dart deleted file mode 100644 index 844321e..0000000 --- a/lib/core/interceptor/response_conversion.interceptor.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:http_interceptor/http_interceptor.dart'; - -const interceptedRoutes = ['thumb/']; - -class ResponseConversionInterceptor implements InterceptorContract { - @override - Future interceptRequest({required BaseRequest request}) async => - request; - - @override - Future interceptResponse( - {required BaseResponse response}) async { - final String requestUrl = - response.request?.url.toString().split("?").first ?? ''; - if (response.request?.method == "GET" && - interceptedRoutes.any((element) => requestUrl.endsWith(element))) { - final resp = response as Response; - - return StreamedResponse( - Stream.value(resp.bodyBytes.toList()).asBroadcastStream(), - resp.statusCode, - contentLength: resp.contentLength, - headers: resp.headers, - isRedirect: resp.isRedirect, - persistentConnection: false, - reasonPhrase: resp.reasonPhrase, - request: resp.request, - ); - } - return response; - } - - @override - Future shouldInterceptRequest() async => true; - - @override - Future shouldInterceptResponse() async => true; -} diff --git a/lib/core/interceptor/retry_on_connection_change_interceptor.dart b/lib/core/interceptor/retry_on_connection_change_interceptor.dart new file mode 100644 index 0000000..74ed9be --- /dev/null +++ b/lib/core/interceptor/retry_on_connection_change_interceptor.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +class RetryOnConnectionChangeInterceptor extends Interceptor { + final Dio dio; + + RetryOnConnectionChangeInterceptor({ + required this.dio, + }); + + @override + void onError(DioError err, ErrorInterceptorHandler handler) async { + if (_shouldRetryOnHttpException(err)) { + try { + handler.resolve(await DioHttpRequestRetrier(dio: dio) + .requestRetry(err.requestOptions) + .catchError((e) { + handler.next(err); + })); + } catch (e) { + handler.next(err); + } + } else { + handler.next(err); + } + } + + bool _shouldRetryOnHttpException(DioError err) { + return err.type == DioErrorType.other && + ((err.error is HttpException && + err.message.contains( + 'Connection closed before full header was received'))); + } +} + +/// Retrier +class DioHttpRequestRetrier { + final Dio dio; + + DioHttpRequestRetrier({ + required this.dio, + }); + + Future requestRetry(RequestOptions requestOptions) async { + return dio.request( + requestOptions.path, + cancelToken: requestOptions.cancelToken, + data: requestOptions.data, + onReceiveProgress: requestOptions.onReceiveProgress, + onSendProgress: requestOptions.onSendProgress, + queryParameters: requestOptions.queryParameters, + options: Options( + contentType: requestOptions.contentType, + headers: requestOptions.headers, + sendTimeout: requestOptions.sendTimeout, + receiveTimeout: requestOptions.receiveTimeout, + extra: requestOptions.extra, + followRedirects: requestOptions.followRedirects, + listFormat: requestOptions.listFormat, + maxRedirects: requestOptions.maxRedirects, + method: requestOptions.method, + receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError, + requestEncoder: requestOptions.requestEncoder, + responseDecoder: requestOptions.responseDecoder, + responseType: requestOptions.responseType, + validateStatus: requestOptions.validateStatus, + ), + ); + } +} diff --git a/lib/core/repository/provider/label_repositories_provider.dart b/lib/core/repository/provider/label_repositories_provider.dart index 759a46c..6dd48ca 100644 --- a/lib/core/repository/provider/label_repositories_provider.dart +++ b/lib/core/repository/provider/label_repositories_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; diff --git a/lib/core/security/authentication_aware_dio_manager.dart b/lib/core/security/authentication_aware_dio_manager.dart index 91da1c7..623177c 100644 --- a/lib/core/security/authentication_aware_dio_manager.dart +++ b/lib/core/security/authentication_aware_dio_manager.dart @@ -3,36 +3,26 @@ import 'dart:io'; import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; -import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; import 'package:paperless_mobile/extensions/security_context_extension.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -/// -/// Convenience http client handling timeouts. -/// class AuthenticationAwareDioManager { - final Dio _dio; + final Dio client; + final List interceptors; /// Some dependencies require an [HttpClient], therefore this is also maintained here. - AuthenticationAwareDioManager() : _dio = _initDio(); + AuthenticationAwareDioManager([this.interceptors = const []]) + : client = _initDio(interceptors); - Dio get client => _dio; - - Stream get securityContextChanges => - _securityContextStreamController.stream.asBroadcastStream(); - - final StreamController _securityContextStreamController = - StreamController.broadcast(); - - static Dio _initDio() { + static Dio _initDio(List interceptors) { //en- and decoded by utf8 by default final Dio dio = Dio(BaseOptions()); dio.options.receiveTimeout = const Duration(seconds: 25).inMilliseconds; dio.options.responseType = ResponseType.json; (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) => client..badCertificateCallback = (cert, host, port) => true; - dio.interceptors.add(DioHttpErrorInterceptor()); + dio.interceptors.addAll(interceptors); return dio; } @@ -42,19 +32,39 @@ class AuthenticationAwareDioManager { ClientCertificate? clientCertificate, }) { if (clientCertificate != null) { - final context = - SecurityContext().withClientCertificate(clientCertificate); - (_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = - (client) => HttpClient(context: context) - ..badCertificateCallback = - (X509Certificate cert, String host, int port) => true; - _securityContextStreamController.add(context); + final context = SecurityContext() + ..usePrivateKeyBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ) + ..useCertificateChainBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ) + ..setTrustedCertificatesBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ); + final adapter = DefaultHttpClientAdapter() + ..onHttpClientCreate = (client) => HttpClient(context: context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + + client.httpClientAdapter = adapter; } + if (baseUrl != null) { - _dio.options.baseUrl = baseUrl; + client.options.baseUrl = baseUrl; } + if (authToken != null) { - _dio.options.headers.addAll({'Authorization': 'Token $authToken'}); + client.options.headers.addAll({'Authorization': 'Token $authToken'}); } } + + void resetSettings() { + client.httpClientAdapter = DefaultHttpClientAdapter(); + client.options.baseUrl = ''; + client.options.headers.remove('Authorization'); + } } diff --git a/lib/core/service/dio_file_service.dart b/lib/core/service/dio_file_service.dart new file mode 100644 index 0000000..39675e8 --- /dev/null +++ b/lib/core/service/dio_file_service.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +// ignore: implementation_imports +import 'package:flutter_cache_manager/src/web/mime_converter.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +class DioFileService extends FileService { + final Dio dio; + + DioFileService(this.dio); + + @override + Future get(String url, + {Map? headers}) async { + final response = await dio.get( + url, + options: Options( + headers: headers, + responseType: ResponseType.stream, + ), + ); + return DioGetResponse(response); + } +} + +class DioGetResponse implements FileServiceResponse { + final Response _response; + + final DateTime _receivedTime = DateTime.now(); + + DioGetResponse(this._response); + + @override + Stream> get content => _response.data!.stream; + + @override + int? get contentLength => int.tryParse( + _response.headers.value(HttpHeaders.contentLengthHeader) ?? '-1'); + + @override + String? get eTag => _response.headers.value(HttpHeaders.etagHeader); + + @override + String get fileExtension { + var fileExtension = ''; + final contentTypeHeader = + _response.headers.value(HttpHeaders.contentTypeHeader); + if (contentTypeHeader != null) { + final contentType = ContentType.parse(contentTypeHeader); + fileExtension = contentType.fileExtension; + } + return fileExtension; + } + + @override + int get statusCode => _response.statusCode ?? 200; + + @override + DateTime get validTill { + // Without a cache-control header we keep the file for a week + var ageDuration = const Duration(days: 7); + final controlHeader = + _response.headers.value(HttpHeaders.cacheControlHeader); + if (controlHeader != null) { + final controlSettings = controlHeader.split(','); + for (final setting in controlSettings) { + final sanitizedSetting = setting.trim().toLowerCase(); + if (sanitizedSetting == 'no-cache') { + ageDuration = const Duration(); + } + if (sanitizedSetting.startsWith('max-age=')) { + var validSeconds = int.tryParse(sanitizedSetting.split('=')[1]) ?? 0; + if (validSeconds > 0) { + ageDuration = Duration(seconds: validSeconds); + } + } + } + } + + return _receivedTime.add(ageDuration); + } +} diff --git a/lib/di_test_mocks.dart b/lib/di_test_mocks.dart deleted file mode 100644 index b36b4a5..0000000 --- a/lib/di_test_mocks.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:injectable/injectable.dart'; -import 'package:local_auth/local_auth.dart'; -import 'package:mockito/annotations.dart'; - -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), - MockSpec(), -]) -import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/service/connectivity_status.service.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; -import 'di_test_mocks.mocks.dart'; - -@module -abstract class DiMocksModule { - // All fields must be singleton in order to verify behavior in tests. - @singleton - @test - CacheManager get testCacheManager => CacheManager(Config('testKey')); - - @singleton - @test - PaperlessDocumentsApi get mockDocumentsApi => MockPaperlessDocumentsApi(); - - @singleton - @test - PaperlessLabelsApi get mockLabelsApi => MockPaperlessLabelsApi(); - - @singleton - @test - PaperlessSavedViewsApi get mockSavedViewsApi => MockPaperlessSavedViewsApi(); - - @singleton - @test - PaperlessAuthenticationApi get mockAuthenticationApi => - MockPaperlessAuthenticationApi(); - - @singleton - @test - PaperlessServerStatsApi get mockServerStatsApi => - MockPaperlessServerStatsApi(); - - @singleton - @test - LocalVault get mockLocalVault => MockLocalVault(); - - @singleton - @test - EncryptedSharedPreferences get mockSharedPreferences => - MockEncryptedSharedPreferences(); - - @singleton - @test - ConnectivityStatusService get mockConnectivityStatusService => - MockConnectivityStatusService(); - - @singleton - @test - LocalAuthentication get localAuthentication => MockLocalAuthentication(); -} diff --git a/lib/features/document_details/view/pages/document_details_page.dart b/lib/features/document_details/view/pages/document_details_page.dart index b64caba..4f767f7 100644 --- a/lib/features/document_details/view/pages/document_details_page.dart +++ b/lib/features/document_details/view/pages/document_details_page.dart @@ -1,14 +1,11 @@ -import 'dart:developer' as dev; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:paperless_api/paperless_api.dart'; -import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/widgets/highlighted_text.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; @@ -25,7 +22,6 @@ import 'package:paperless_mobile/features/labels/tags/view/widgets/tags_widget.d import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/util.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; class DocumentDetailsPage extends StatefulWidget { diff --git a/lib/features/document_upload/cubit/document_upload_cubit.dart b/lib/features/document_upload/cubit/document_upload_cubit.dart index 3622f5e..9295727 100644 --- a/lib/features/document_upload/cubit/document_upload_cubit.dart +++ b/lib/features/document_upload/cubit/document_upload_cubit.dart @@ -58,10 +58,6 @@ class DocumentUploadCubit extends Cubit { Iterable tags = const [], DateTime? createdAt, }) async { - final auth = await _localVault.loadAuthenticationInformation(); - if (auth == null || !auth.isValid) { - throw const PaperlessServerException(ErrorCode.notAuthenticated); - } await _documentApi.create( bytes, filename: filename, diff --git a/lib/features/document_upload/view/document_upload_preparation_page.dart b/lib/features/document_upload/view/document_upload_preparation_page.dart index 8702bee..6e52bc4 100644 --- a/lib/features/document_upload/view/document_upload_preparation_page.dart +++ b/lib/features/document_upload/view/document_upload_preparation_page.dart @@ -245,8 +245,8 @@ class _DocumentUploadPreparationPageState Navigator.pop(context, true); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); - } on PaperlessValidationErrors catch (PaperlessServerExceptions) { - setState(() => _errors = PaperlessServerExceptions); + } on PaperlessValidationErrors catch (errors) { + setState(() => _errors = errors); } catch (unknownError, stackTrace) { showErrorMessage( context, const PaperlessServerException.unknown(), stackTrace); diff --git a/lib/features/documents/bloc/documents_cubit.dart b/lib/features/documents/bloc/documents_cubit.dart index 3f64d6f..09ab8e8 100644 --- a/lib/features/documents/bloc/documents_cubit.dart +++ b/lib/features/documents/bloc/documents_cubit.dart @@ -1,8 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; - -part 'documents_state.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; class DocumentsCubit extends Cubit { final PaperlessDocumentsApi _api; diff --git a/lib/features/documents/bloc/documents_state.dart b/lib/features/documents/bloc/documents_state.dart index 80ad056..2b0e19a 100644 --- a/lib/features/documents/bloc/documents_state.dart +++ b/lib/features/documents/bloc/documents_state.dart @@ -1,9 +1,14 @@ -part of 'documents_cubit.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/paperless_api.dart'; +@JsonSerializable() class DocumentsState extends Equatable { final bool isLoaded; final DocumentFilter filter; final List value; + + @JsonKey(ignore: true) final List selection; const DocumentsState({ diff --git a/lib/features/documents/view/pages/documents_page.dart b/lib/features/documents/view/pages/documents_page.dart index 047c752..47e86cf 100644 --- a/lib/features/documents/view/pages/documents_page.dart +++ b/lib/features/documents/view/pages/documents_page.dart @@ -7,6 +7,7 @@ import 'package:paperless_mobile/core/repository/provider/label_repositories_pro import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.dart'; import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/documents_empty_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid.dart'; import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.dart'; @@ -82,6 +83,8 @@ class _DocumentsPageState extends State { -4), //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd isLabelVisible: appliedFiltersCount > 0, count: state.filter.appliedFiltersCount, + backgroundColor: Theme.of(context).colorScheme.errorContainer, + textColor: Theme.of(context).colorScheme.onErrorContainer, child: FloatingActionButton( child: const Icon(Icons.filter_alt_outlined), onPressed: _openDocumentFilter, diff --git a/lib/features/documents/view/widgets/document_preview.dart b/lib/features/documents/view/widgets/document_preview.dart index 5ab83e0..c4107b9 100644 --- a/lib/features/documents/view/widgets/document_preview.dart +++ b/lib/features/documents/view/widgets/document_preview.dart @@ -10,21 +10,30 @@ class DocumentPreview extends StatelessWidget { final BoxFit fit; final Alignment alignment; final double borderRadius; + final bool enableHero; const DocumentPreview({ - Key? key, + super.key, required this.id, this.fit = BoxFit.cover, this.alignment = Alignment.center, this.borderRadius = 8.0, - }) : super(key: key); + this.enableHero = true, + }); @override Widget build(BuildContext context) { - return - // Hero( - // tag: "document_$id",child: - ClipRRect( + if (!enableHero) { + return _buildPreview(context); + } + return Hero( + tag: "thumb_$id", + child: _buildPreview(context), + ); + } + + ClipRRect _buildPreview(BuildContext context) { + return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: CachedNetworkImage( fit: fit, @@ -39,7 +48,6 @@ class DocumentPreview extends StatelessWidget { ), cacheManager: context.watch(), ), - // ), ); } } diff --git a/lib/features/documents/view/widgets/documents_empty_state.dart b/lib/features/documents/view/widgets/documents_empty_state.dart index 7c87d06..050507c 100644 --- a/lib/features/documents/view/widgets/documents_empty_state.dart +++ b/lib/features/documents/view/widgets/documents_empty_state.dart @@ -3,6 +3,7 @@ import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class DocumentsEmptyState extends StatelessWidget { diff --git a/lib/features/documents/view/widgets/grid/document_grid.dart b/lib/features/documents/view/widgets/grid/document_grid.dart index 15f73c1..5a9c23f 100644 --- a/lib/features/documents/view/widgets/grid/document_grid.dart +++ b/lib/features/documents/view/widgets/grid/document_grid.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; diff --git a/lib/features/documents/view/widgets/list/document_list.dart b/lib/features/documents/view/widgets/list/document_list.dart index 8fac591..a28cb97 100644 --- a/lib/features/documents/view/widgets/list/document_list.dart +++ b/lib/features/documents/view/widgets/list/document_list.dart @@ -4,6 +4,7 @@ import 'package:paperless_mobile/core/repository/provider/label_repositories_pro import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.dart'; import 'package:paperless_mobile/core/widgets/offline_widget.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; diff --git a/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart b/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart index 74657ed..90a51ae 100644 --- a/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart +++ b/lib/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/generated/l10n.dart'; class BulkDeleteConfirmationDialog extends StatelessWidget { diff --git a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart index 1a1b3ac..07b7431 100644 --- a/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart +++ b/lib/features/documents/view/widgets/selection/documents_page_app_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/widgets/offline_banner.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/bulk_delete_confirmation_dialog.dart'; import 'package:paperless_mobile/features/saved_view/view/saved_view_selection_widget.dart'; import 'package:paperless_mobile/generated/l10n.dart'; diff --git a/lib/features/documents/view/widgets/sort_documents_button.dart b/lib/features/documents/view/widgets/sort_documents_button.dart index 4806d3e..27bc601 100644 --- a/lib/features/documents/view/widgets/sort_documents_button.dart +++ b/lib/features/documents/view/widgets/sort_documents_button.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; diff --git a/lib/features/edit_label/view/label_form.dart b/lib/features/edit_label/view/label_form.dart index 7938932..3bb1bb4 100644 --- a/lib/features/edit_label/view/label_form.dart +++ b/lib/features/edit_label/view/label_form.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; @@ -120,10 +121,10 @@ class _LabelFormState extends State> { final createdLabel = await widget.submitButtonConfig .onSubmit(widget.fromJsonT(mergedJson)); Navigator.pop(context, createdLabel); - } on PaperlessValidationErrors catch (errorMessages) { - setState(() => _errors = errorMessages); } on PaperlessServerException catch (error, stackTrace) { showErrorMessage(context, error, stackTrace); + } on DioError catch (error) { + setState(() => _errors = error.error as PaperlessValidationErrors); } } } diff --git a/lib/features/inbox/view/widgets/inbox_item.dart b/lib/features/inbox/view/widgets/inbox_item.dart index d657e74..2759b54 100644 --- a/lib/features/inbox/view/widgets/inbox_item.dart +++ b/lib/features/inbox/view/widgets/inbox_item.dart @@ -30,6 +30,7 @@ class InboxItem extends StatelessWidget { id: document.id, fit: BoxFit.cover, alignment: Alignment.topCenter, + enableHero: false, ), ), subtitle: Column( diff --git a/lib/features/login/bloc/authentication_cubit.dart b/lib/features/login/bloc/authentication_cubit.dart index 4757309..d0cbee7 100644 --- a/lib/features/login/bloc/authentication_cubit.dart +++ b/lib/features/login/bloc/authentication_cubit.dart @@ -3,22 +3,19 @@ import 'dart:io'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.dart'; -import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_state.dart'; import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/user_credentials.model.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; -import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; -class AuthenticationCubit extends HydratedCubit { +class AuthenticationCubit extends Cubit + with HydratedMixin { final LocalAuthenticationService _localAuthService; final PaperlessAuthenticationApi _authApi; - final LocalVault _localVault; final AuthenticationAwareDioManager _dioWrapper; AuthenticationCubit( - this._localVault, this._localAuthService, this._authApi, this._dioWrapper, @@ -31,6 +28,7 @@ class AuthenticationCubit extends HydratedCubit { }) async { assert(credentials.username != null && credentials.password != null); try { + print(_dioWrapper.client.hashCode); _dioWrapper.updateSettings( baseUrl: serverUrl, clientCertificate: clientCertificate, @@ -41,19 +39,22 @@ class AuthenticationCubit extends HydratedCubit { password: credentials.password!, ); - final auth = AuthenticationInformation( - serverUrl: serverUrl, + _dioWrapper.updateSettings( + baseUrl: serverUrl, clientCertificate: clientCertificate, - token: token, + authToken: token, ); - await _localVault.storeAuthenticationInformation(auth); - - emit(AuthenticationState( - isAuthenticated: true, - wasLoginStored: false, - authentication: auth, - )); + emit( + AuthenticationState( + wasLoginStored: false, + authentication: AuthenticationInformation( + serverUrl: serverUrl, + clientCertificate: clientCertificate, + token: token, + ), + ), + ); } on TlsException catch (_) { const error = PaperlessServerException( ErrorCode.invalidClientCertificateConfiguration); @@ -67,59 +68,68 @@ class AuthenticationCubit extends HydratedCubit { } } - Future restoreSessionState() async { - final storedAuth = await _localVault.loadAuthenticationInformation(); - late ApplicationSettingsState? appSettings; - try { - appSettings = await _localVault.loadApplicationSettings() ?? - ApplicationSettingsState.defaultSettings; - } catch (err) { - appSettings = ApplicationSettingsState.defaultSettings; + /// + /// Performs a conditional hydration based on the local authentication success. + /// + Future restoreSessionState(bool promptForLocalAuthentication) async { + final json = HydratedBloc.storage.read(storageToken); + + if (json == null) { + // If there is nothing to restore, we can quit here. + return; } - if (storedAuth == null || !storedAuth.isValid) { - return emit( - AuthenticationState(isAuthenticated: false, wasLoginStored: false), - ); - } else { - if (appSettings.isLocalAuthenticationEnabled) { - final localAuthSuccess = await _localAuthService - .authenticateLocalUser("Authenticate to log back in"); - if (localAuthSuccess) { + + if (promptForLocalAuthentication) { + final localAuthSuccess = await _localAuthService + .authenticateLocalUser("Authenticate to log back in"); + if (localAuthSuccess) { + hydrate(); + if (state.isAuthenticated) { _dioWrapper.updateSettings( - clientCertificate: storedAuth.clientCertificate, + clientCertificate: state.authentication!.clientCertificate, + authToken: state.authentication!.token, + baseUrl: state.authentication!.serverUrl, ); return emit( AuthenticationState( - isAuthenticated: true, wasLoginStored: true, - authentication: storedAuth, + authentication: state.authentication, wasLocalAuthenticationSuccessful: true, ), ); - } else { - return emit(AuthenticationState( - isAuthenticated: false, - wasLoginStored: true, - wasLocalAuthenticationSuccessful: false, - )); } } else { + hydrate(); + return emit( + AuthenticationState( + wasLoginStored: true, + wasLocalAuthenticationSuccessful: false, + authentication: state.authentication, + ), + ); + } + } else { + hydrate(); + if (state.isAuthenticated) { _dioWrapper.updateSettings( - clientCertificate: storedAuth.clientCertificate, + clientCertificate: state.authentication!.clientCertificate, + authToken: state.authentication!.token, + baseUrl: state.authentication!.serverUrl, ); final authState = AuthenticationState( - isAuthenticated: true, - authentication: storedAuth, + authentication: state.authentication!, wasLoginStored: true, ); return emit(authState); + } else { + return emit(AuthenticationState.initial); } } } Future logout() async { - await _localVault.clear(); - await super.clear(); + await clear(); + _dioWrapper.resetSettings(); emit(AuthenticationState.initial); } diff --git a/lib/features/login/bloc/authentication_state.dart b/lib/features/login/bloc/authentication_state.dart index 9be2ab5..2f39de4 100644 --- a/lib/features/login/bloc/authentication_state.dart +++ b/lib/features/login/bloc/authentication_state.dart @@ -6,21 +6,20 @@ part 'authentication_state.g.dart'; @JsonSerializable() class AuthenticationState { final bool wasLoginStored; + @JsonKey(ignore: true) final bool? wasLocalAuthenticationSuccessful; - final bool isAuthenticated; final AuthenticationInformation? authentication; static final AuthenticationState initial = AuthenticationState( wasLoginStored: false, - isAuthenticated: false, ); + bool get isAuthenticated => authentication != null; AuthenticationState({ - required this.isAuthenticated, required this.wasLoginStored, this.wasLocalAuthenticationSuccessful, this.authentication, - }) : assert(!isAuthenticated || authentication != null); + }); AuthenticationState copyWith({ bool? wasLoginStored, @@ -29,7 +28,6 @@ class AuthenticationState { bool? wasLocalAuthenticationSuccessful, }) { return AuthenticationState( - isAuthenticated: isAuthenticated ?? this.isAuthenticated, wasLoginStored: wasLoginStored ?? this.wasLoginStored, authentication: authentication ?? this.authentication, wasLocalAuthenticationSuccessful: wasLocalAuthenticationSuccessful ?? diff --git a/lib/features/login/bloc/authentication_state.g.dart b/lib/features/login/bloc/authentication_state.g.dart index 79c879d..f98cdf3 100644 --- a/lib/features/login/bloc/authentication_state.g.dart +++ b/lib/features/login/bloc/authentication_state.g.dart @@ -8,10 +8,7 @@ part of 'authentication_state.dart'; AuthenticationState _$AuthenticationStateFromJson(Map json) => AuthenticationState( - isAuthenticated: json['isAuthenticated'] as bool, wasLoginStored: json['wasLoginStored'] as bool, - wasLocalAuthenticationSuccessful: - json['wasLocalAuthenticationSuccessful'] as bool?, authentication: json['authentication'] == null ? null : AuthenticationInformation.fromJson( @@ -22,8 +19,5 @@ Map _$AuthenticationStateToJson( AuthenticationState instance) => { 'wasLoginStored': instance.wasLoginStored, - 'wasLocalAuthenticationSuccessful': - instance.wasLocalAuthenticationSuccessful, - 'isAuthenticated': instance.isAuthenticated, 'authentication': instance.authentication, }; diff --git a/lib/features/login/view/login_page.dart b/lib/features/login/view/login_page.dart index 5da78a0..fa4656d 100644 --- a/lib/features/login/view/login_page.dart +++ b/lib/features/login/view/login_page.dart @@ -49,7 +49,7 @@ class _LoginPageState extends State { padding: const EdgeInsets.only(top: 16.0), child: Text( S.of(context).loginPageAdvancedLabel, - style: Theme.of(context).textTheme.bodyText1, + style: Theme.of(context).textTheme.bodyLarge, ).padded(), ), ), diff --git a/lib/features/login/view/widgets/server_address_form_field.dart b/lib/features/login/view/widgets/server_address_form_field.dart index 03e5b47..db2bba7 100644 --- a/lib/features/login/view/widgets/server_address_form_field.dart +++ b/lib/features/login/view/widgets/server_address_form_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:paperless_mobile/core/service/connectivity_status.service.dart'; import 'package:paperless_mobile/generated/l10n.dart'; @@ -26,6 +27,10 @@ class _ServerAddressFormFieldState extends State { validator: FormBuilderValidators.required( errorText: S.of(context).loginPageServerUrlValidatorMessageText, ), + inputFormatters: [ + FilteringTextInputFormatter.deny(r".*/$"), + FilteringTextInputFormatter.deny(r"\s"), + ], decoration: InputDecoration( suffixIcon: _buildIsReachableIcon(), hintText: "http://192.168.1.50:8000", diff --git a/lib/features/saved_view/view/saved_view_selection_widget.dart b/lib/features/saved_view/view/saved_view_selection_widget.dart index 6ba4247..d996bbf 100644 --- a/lib/features/saved_view/view/saved_view_selection_widget.dart +++ b/lib/features/saved_view/view/saved_view_selection_widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import 'package:paperless_mobile/features/documents/view/widgets/selection/confirm_delete_saved_view_dialog.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_cubit.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; diff --git a/lib/features/settings/bloc/application_settings_cubit.dart b/lib/features/settings/bloc/application_settings_cubit.dart index e62699d..8d738ff 100644 --- a/lib/features/settings/bloc/application_settings_cubit.dart +++ b/lib/features/settings/bloc/application_settings_cubit.dart @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart'; class ApplicationSettingsCubit extends HydratedCubit { - ApplicationSettingsCubit() : super(ApplicationSettingsState.defaultSettings); + final LocalAuthenticationService _localAuthenticationService; + ApplicationSettingsCubit(this._localAuthenticationService) + : super(ApplicationSettingsState.defaultSettings); Future setLocale(String? localeSubtag) async { final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag); _updateSettings(updatedSettings); } - Future setIsBiometricAuthenticationEnabled(bool isEnabled) async { - final updatedSettings = - state.copyWith(isLocalAuthenticationEnabled: isEnabled); - _updateSettings(updatedSettings); + Future setIsBiometricAuthenticationEnabled( + bool isEnabled, { + required String localizedReason, + }) async { + final isActionAuthorized = await _localAuthenticationService + .authenticateLocalUser(localizedReason); + if (isActionAuthorized) { + final updatedSettings = + state.copyWith(isLocalAuthenticationEnabled: isEnabled); + _updateSettings(updatedSettings); + } } Future setThemeMode(ThemeMode? selectedMode) async { diff --git a/lib/features/settings/view/widgets/biometric_authentication_setting.dart b/lib/features/settings/view/widgets/biometric_authentication_setting.dart index bedd7e6..3a49d6e 100644 --- a/lib/features/settings/view/widgets/biometric_authentication_setting.dart +++ b/lib/features/settings/view/widgets/biometric_authentication_setting.dart @@ -19,7 +19,6 @@ class BiometricAuthenticationSetting extends StatelessWidget { subtitle: Text( S.of(context).appSettingsBiometricAuthenticationDescriptionText), onChanged: (val) async { - final settingsBloc = context.read(); final String localizedReason = val ? S .of(context) @@ -27,12 +26,10 @@ class BiometricAuthenticationSetting extends StatelessWidget { : S .of(context) .appSettingsDisableBiometricAuthenticationReasonText; - final changeValue = await context - .read() - .authenticateLocalUser(localizedReason); - if (changeValue) { - settingsBloc.setIsBiometricAuthenticationEnabled(val); - } + await context + .read() + .setIsBiometricAuthenticationEnabled(val, + localizedReason: localizedReason); }, ); }, diff --git a/lib/main.dart b/lib/main.dart index c1f3979..336fb4a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart'; @@ -10,9 +11,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; -import 'package:http_interceptor/http/intercepted_client.dart'; +import 'package:hive/hive.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl_standalone.dart'; @@ -22,7 +21,9 @@ import 'package:paperless_mobile/core/bloc/bloc_changes_observer.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/paperless_server_information_cubit.dart'; import 'package:paperless_mobile/core/global/constants.dart'; -import 'package:paperless_mobile/core/interceptor/authentication.interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/core/logic/error_code_localization_mapper.dart'; import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/document_type_repository_impl.dart'; @@ -33,17 +34,16 @@ import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/saved_view_repository.dart'; import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.dart'; import 'package:paperless_mobile/core/service/connectivity_status.service.dart'; +import 'package:paperless_mobile/core/service/dio_file_service.dart'; import 'package:paperless_mobile/core/service/file_service.dart'; import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart'; -import 'package:paperless_mobile/extensions/security_context_extension.dart'; import 'package:paperless_mobile/features/app_intro/application_intro_slideshow.dart'; import 'package:paperless_mobile/features/document_upload/cubit/document_upload_cubit.dart'; import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.dart'; import 'package:paperless_mobile/features/home/view/home_page.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart'; import 'package:paperless_mobile/features/login/bloc/authentication_state.dart'; -import 'package:paperless_mobile/features/login/model/authentication_information.dart'; import 'package:paperless_mobile/features/login/services/authentication_service.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; @@ -53,24 +53,48 @@ import 'package:paperless_mobile/util.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; void main() async { Bloc.observer = BlocChangesObserver(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - HydratedBloc.storage = await HydratedStorage.build( - storageDirectory: await getApplicationDocumentsDirectory(), - ); - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await findSystemLocale(); - // Required for self signed client certificates - final dioWrapper = AuthenticationAwareDioManager(); - // Initialize External dependencies final connectivity = Connectivity(); final encryptedSharedPreferences = EncryptedSharedPreferences(); final localAuthentication = LocalAuthentication(); + // Initialize other utility classes + final connectivityStatusService = ConnectivityStatusServiceImpl(connectivity); + final localVault = LocalVaultImpl(encryptedSharedPreferences); + final localAuthService = + LocalAuthenticationService(localVault, localAuthentication); + + final hiveDir = await getApplicationDocumentsDirectory(); + HydratedBloc.storage = await HydratedStorage.build( + storageDirectory: hiveDir, + ); + + final appSettingsCubit = ApplicationSettingsCubit(localAuthService); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + + final languageHeaderInterceptor = LanguageHeaderInterceptor( + appSettingsCubit.state.preferredLocaleSubtag, + ); + // Required for self signed client certificates + final dioWrapper = AuthenticationAwareDioManager([ + DioHttpErrorInterceptor(), + PrettyDioLogger( + compact: true, + responseBody: false, + responseHeader: false, + request: false, + requestBody: false, + requestHeader: false, + ), + languageHeaderInterceptor, + ]); // Initialize Paperless APIs final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client); @@ -79,14 +103,6 @@ void main() async { final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client); final savedViewsApi = PaperlessSavedViewsApiImpl(dioWrapper.client); - // Initialize other utility classes - final connectivityStatusService = ConnectivityStatusServiceImpl(connectivity); - final localVault = LocalVaultImpl(encryptedSharedPreferences); - final localAuthService = - LocalAuthenticationService(localVault, localAuthentication); - - // Initialize Repositories - // Initialize Blocs/Cubits final connectivityCubit = ConnectivityCubit(connectivityStatusService); // Remove temporarily downloaded files. @@ -95,15 +111,21 @@ void main() async { // Load application settings and stored authentication data await connectivityCubit.initialize(); + // Create repositories + final tagRepository = TagRepositoryImpl(labelsApi); + final correspondentRepository = CorrespondentRepositoryImpl(labelsApi); + final documentTypeRepository = DocumentTypeRepositoryImpl(labelsApi); + final storagePathRepository = StoragePathRepositoryImpl(labelsApi); + final savedViewRepository = SavedViewRepositoryImpl(savedViewsApi); + + //Create cubits/blocs final authCubit = AuthenticationCubit( - localVault, localAuthService, authApi, dioWrapper, ); - - String? currentServerUrl; - String? currentAuthToken; + await authCubit + .restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled); if (authCubit.state.isAuthenticated) { final auth = authCubit.state.authentication!; @@ -112,31 +134,11 @@ void main() async { authToken: auth.token, clientCertificate: auth.clientCertificate, ); - currentServerUrl = auth.serverUrl; - currentAuthToken = auth.token; } - SecurityContext securityContext = SecurityContext(); - authCubit.stream.asBroadcastStream().listen((event) { - if (event.isAuthenticated) { - final auth = event.authentication!; - securityContext = - SecurityContext().withClientCertificate(auth.clientCertificate); - currentServerUrl = auth.serverUrl; - currentAuthToken = auth.token; - } else { - securityContext = SecurityContext(); - currentServerUrl = null; - currentAuthToken = null; - } - }); - - // Create repositories - final tagRepository = TagRepositoryImpl(labelsApi); - final correspondentRepository = CorrespondentRepositoryImpl(labelsApi); - final documentTypeRepository = DocumentTypeRepositoryImpl(labelsApi); - final storagePathRepository = StoragePathRepositoryImpl(labelsApi); - final savedViewRepository = SavedViewRepositoryImpl(savedViewsApi); + //Update language header in interceptor on language change. + appSettingsCubit.stream.listen((event) => languageHeaderInterceptor + .preferredLocaleSubtag = event.preferredLocaleSubtag); runApp( MultiProvider( @@ -146,44 +148,11 @@ void main() async { Provider.value(value: labelsApi), Provider.value(value: statsApi), Provider.value(value: savedViewsApi), - ProxyProvider( + Provider( create: (context) => cm.CacheManager( cm.Config( 'cacheKey', - fileService: cm.HttpFileService( - httpClient: InterceptedClient.build( - interceptors: [ - AuthenticationInterceptor( - serverUrl: currentServerUrl, - token: currentAuthToken, - ), - ], - client: IOClient( - HttpClient( - context: securityContext, - ), - ), - ), - ), - ), - ), - update: (context, securityContext, previous) => cm.CacheManager( - cm.Config( - 'cacheKey', - fileService: cm.HttpFileService( - httpClient: InterceptedClient.build( - interceptors: [ - AuthenticationInterceptor( - serverUrl: currentServerUrl, - token: currentAuthToken, - ), - ], - client: IOClient( - HttpClient( - context: securityContext, - ), - ), - )), + fileService: DioFileService(dioWrapper.client), ), ), ), @@ -214,6 +183,8 @@ void main() async { providers: [ BlocProvider.value(value: authCubit), BlocProvider.value(value: connectivityCubit), + BlocProvider.value( + value: appSettingsCubit), ], child: const PaperlessMobileEntrypoint(), ), @@ -282,9 +253,6 @@ class _PaperlessMobileEntrypointState extends State { BlocProvider( create: (context) => PaperlessServerInformationCubit(context.read()), ), - BlocProvider( - create: (context) => ApplicationSettingsCubit(), - ), ], child: BlocBuilder( builder: (context, settings) { @@ -423,7 +391,8 @@ class _AuthenticationWrapperState extends State { } }, builder: (context, authentication) { - if (authentication.isAuthenticated) { + if (authentication.isAuthenticated && + (authentication.wasLocalAuthenticationSuccessful ?? true)) { return const HomePage(); } else { if (authentication.wasLoginStored && @@ -461,12 +430,20 @@ class BiometricAuthenticationPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ElevatedButton( - onPressed: () => context.read().logout(), + onPressed: () { + context.read().logout(); + context.read(); + HydratedBloc.storage.clear(); + }, child: const Text("Log out"), ), ElevatedButton( - onPressed: () => - context.read().restoreSessionState(), + onPressed: () => context + .read() + .restoreSessionState(context + .read() + .state + .isLocalAuthenticationEnabled), child: const Text("Authenticate"), ), ], diff --git a/packages/paperless_api/lib/src/converters/id_query_parameter_json_converter.dart b/packages/paperless_api/lib/src/converters/id_query_parameter_json_converter.dart new file mode 100644 index 0000000..0e1f5ad --- /dev/null +++ b/packages/paperless_api/lib/src/converters/id_query_parameter_json_converter.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/src/models/models.dart'; + +class IdQueryParameterJsonConverter + extends JsonConverter> { + const IdQueryParameterJsonConverter(); + static const _idKey = "id"; + static const _assignmentStatusKey = 'assignmentStatus'; + @override + IdQueryParameter fromJson(Map json) { + return IdQueryParameter(json[_assignmentStatusKey], json[_idKey]); + } + + @override + Map toJson(IdQueryParameter object) { + return { + _idKey: object.id, + _assignmentStatusKey: object.assignmentStatus, + }; + } +} diff --git a/packages/paperless_api/lib/src/models/document_filter.dart b/packages/paperless_api/lib/src/models/document_filter.dart index 463f8eb..08b3374 100644 --- a/packages/paperless_api/lib/src/models/document_filter.dart +++ b/packages/paperless_api/lib/src/models/document_filter.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/paperless_api.dart'; +@JsonSerializable() class DocumentFilter extends Equatable { static const _oneDay = Duration(days: 1); static const DocumentFilter initial = DocumentFilter(); diff --git a/packages/paperless_api/lib/src/models/paged_search_result.dart b/packages/paperless_api/lib/src/models/paged_search_result.dart index adc8500..eb6685a 100644 --- a/packages/paperless_api/lib/src/models/paged_search_result.dart +++ b/packages/paperless_api/lib/src/models/paged_search_result.dart @@ -1,8 +1,10 @@ import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:paperless_api/src/models/document_model.dart'; const pageRegex = r".*page=(\d+).*"; +//Todo: make this an interface and delegate serialization to implementations class PagedSearchResultJsonSerializer { final Map json; final T Function(Map) fromJson; @@ -10,6 +12,7 @@ class PagedSearchResultJsonSerializer { PagedSearchResultJsonSerializer(this.json, this.fromJson); } +@JsonSerializable() class PagedSearchResult extends Equatable { /// Total number of available items final int count; diff --git a/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart b/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart index 2fac2a8..9a6f1a3 100644 --- a/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart +++ b/packages/paperless_api/lib/src/models/query_parameters/id_query_parameter.dart @@ -1,9 +1,17 @@ import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:paperless_api/src/converters/id_query_parameter_json_converter.dart'; +@IdQueryParameterJsonConverter() +@JsonSerializable() class IdQueryParameter extends Equatable { final int? _assignmentStatus; final int? _id; + @Deprecated("Use named constructors, this is only meant for code generation") + const IdQueryParameter(this._assignmentStatus, this._id); + const IdQueryParameter.notAssigned() : _assignmentStatus = 1, _id = null; @@ -28,6 +36,9 @@ class IdQueryParameter extends Equatable { int? get id => _id; + @visibleForTesting + int? get assignmentStatus => _assignmentStatus; + Map toQueryParameter(String field) { final Map params = {}; if (onlyNotAssigned || onlyAssigned) { diff --git a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart index 9138256..6e985a3 100644 --- a/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/authentication_api/authentication_api_impl.dart @@ -12,6 +12,7 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { required String username, required String password, }) async { + print(client.hashCode); late Response response; try { response = await client.post( @@ -21,27 +22,19 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi { "password": password, }, ); - } on FormatException catch (e) { - final source = e.source; - if (source is String && - source.contains("400 No required SSL certificate was sent")) { + } on DioError catch (error) { + if (error.error is ErrorCode) { throw PaperlessServerException( - ErrorCode.missingClientCertificate, - httpStatusCode: response.statusCode, + error.error, + httpStatusCode: error.response?.statusCode, ); + } else { + throw error.error; } } + if (response.statusCode == 200) { return response.data['token']; - } else if (response.statusCode == 400 && - response - .data //TODO: Check if text is included in statusMessage instead of body - .toLowerCase() - .contains("no required certificate was sent")) { - throw PaperlessServerException( - ErrorCode.invalidClientCertificateConfiguration, - httpStatusCode: response.statusCode, - ); } else { throw PaperlessServerException( ErrorCode.authenticationFailed, diff --git a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart index df2b1a4..0cde468 100644 --- a/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart +++ b/packages/paperless_api/lib/src/modules/documents_api/paperless_documents_api_impl.dart @@ -15,12 +15,19 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { Uint8List documentBytes, { required String filename, required String title, + String contentType = 'application/octet-stream', DateTime? createdAt, int? documentType, int? correspondent, Iterable tags = const [], }) async { - final formData = FormData(); + final formData = FormData() + ..files.add( + MapEntry( + 'document', + MultipartFile.fromBytes(documentBytes, filename: filename), + ), + ); formData.fields.add(MapEntry('title', title)); if (createdAt != null) { @@ -35,6 +42,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi { for (final tag in tags) { formData.fields.add(MapEntry('tags', tag.toString())); } + final response = await client.post('/api/documents/post_document/', data: formData); if (response.statusCode != 200) { diff --git a/pubspec.lock b/pubspec.lock index 7286c18..abe9a2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1236,6 +1236,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "948f7eeb36e7aa0760b51c1a8e3331d4b21e36fabd39efca81f585ed93893544" + url: "https://pub.dev" + source: hosted + version: "1.2.0-beta-1" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b860c71..3258ba5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: dio: ^4.0.6 hydrated_bloc: ^9.0.0 json_annotation: ^4.7.0 + pretty_dio_logger: ^1.2.0-beta-1 dev_dependencies: diff --git a/test/src/bloc/document_cubit_test.dart b/test/src/bloc/document_cubit_test.dart index 67c4c5d..44465e5 100644 --- a/test/src/bloc/document_cubit_test.dart +++ b/test/src/bloc/document_cubit_test.dart @@ -4,6 +4,7 @@ import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:paperless_mobile/features/documents/bloc/documents_state.dart'; import '../../utils.dart'; @GenerateNiceMocks([MockSpec()]) @@ -23,6 +24,7 @@ void main() async { await loadCollection("test/fixtures/correspondents/correspondents.json", Correspondent.fromJson), ); + final List documentTypes = List.unmodifiable( await loadCollection("test/fixtures/document_types/document_types.json", DocumentType.fromJson),