Fixed login and error handling

This commit is contained in:
Anton Stubenbord
2022-12-30 01:28:43 +01:00
parent bf0e186646
commit 2326c9d1d6
43 changed files with 502 additions and 447 deletions

View File

@@ -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<BaseRequest> 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<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -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<BaseRequest> 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<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -1,38 +1,62 @@
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/type/types.dart'; import 'package:paperless_mobile/core/type/types.dart';
class DioHttpErrorInterceptor implements InterceptorsWrapper { class DioHttpErrorInterceptor extends Interceptor {
@override @override
void onError(DioError e, ErrorInterceptorHandler handler) { void onError(DioError err, ErrorInterceptorHandler handler) {
//TODO: Implement and debug how error handling works, or if request has to be resolved. if (err.response?.statusCode == 400) {
if (e.response?.statusCode == 400) {
// try to parse contained error message, otherwise return response // try to parse contained error message, otherwise return response
final Map<String, dynamic> json = jsonDecode(e.response?.data); final dynamic data = err.response?.data;
final PaperlessValidationErrors errorMessages = {}; if (data is Map<String, dynamic>) {
for (final entry in json.entries) { return _handlePaperlessValidationError(data, handler, err);
if (entry.value is List) { } else if (data is String) {
errorMessages.putIfAbsent( return _handlePlainError(data, handler, err);
entry.key, () => (entry.value as List).cast<String>().first);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
errorMessages.putIfAbsent(entry.key, () => entry.value.toString());
}
} }
throw errorMessages;
} }
handler.next(e); handler.reject(err);
} }
@override void _handlePaperlessValidationError(
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { Map<String, dynamic> json,
handler.next(options); 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<String>().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 _handlePlainError(
void onResponse(Response response, ResponseInterceptorHandler handler) { String data,
handler.next(response); 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,
),
);
}
} }
} }

View File

@@ -1,32 +1,19 @@
import 'package:dio/dio.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
class LanguageHeaderInterceptor implements InterceptorContract { class LanguageHeaderInterceptor extends Interceptor {
final ApplicationSettingsCubit appSettingsCubit; String preferredLocaleSubtag;
LanguageHeaderInterceptor(this.preferredLocaleSubtag);
LanguageHeaderInterceptor(this.appSettingsCubit);
@override @override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
late String languages; late String languages;
if (appSettingsCubit.state.preferredLocaleSubtag == "en") { if (preferredLocaleSubtag == "en") {
languages = "en"; languages = "en";
} else { } else {
languages = appSettingsCubit.state.preferredLocaleSubtag + languages = "$preferredLocaleSubtag,en;q=0.7,en-US;q=0.6";
",en;q=0.7,en-US;q=0.6";
} }
request.headers.addAll({"Accept-Language": languages}); options.headers.addAll({"Accept-Language": languages});
return request; handler.next(options);
} }
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
} }

View File

@@ -1,38 +0,0 @@
import 'package:http_interceptor/http_interceptor.dart';
const interceptedRoutes = ['thumb/'];
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async =>
request;
@override
Future<BaseResponse> 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<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -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<Response> 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,
),
);
}
}

View File

@@ -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:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart'; import 'package:paperless_mobile/core/repository/label_repository.dart';

View File

@@ -3,36 +3,26 @@ import 'dart:io';
import 'package:dio/adapter.dart'; import 'package:dio/adapter.dart';
import 'package:dio/dio.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/extensions/security_context_extension.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart';
///
/// Convenience http client handling timeouts.
///
class AuthenticationAwareDioManager { class AuthenticationAwareDioManager {
final Dio _dio; final Dio client;
final List<Interceptor> interceptors;
/// Some dependencies require an [HttpClient], therefore this is also maintained here. /// 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; static Dio _initDio(List<Interceptor> interceptors) {
Stream<SecurityContext> get securityContextChanges =>
_securityContextStreamController.stream.asBroadcastStream();
final StreamController<SecurityContext> _securityContextStreamController =
StreamController.broadcast();
static Dio _initDio() {
//en- and decoded by utf8 by default //en- and decoded by utf8 by default
final Dio dio = Dio(BaseOptions()); final Dio dio = Dio(BaseOptions());
dio.options.receiveTimeout = const Duration(seconds: 25).inMilliseconds; dio.options.receiveTimeout = const Duration(seconds: 25).inMilliseconds;
dio.options.responseType = ResponseType.json; dio.options.responseType = ResponseType.json;
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) => client..badCertificateCallback = (cert, host, port) => true; (client) => client..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.add(DioHttpErrorInterceptor()); dio.interceptors.addAll(interceptors);
return dio; return dio;
} }
@@ -42,19 +32,39 @@ class AuthenticationAwareDioManager {
ClientCertificate? clientCertificate, ClientCertificate? clientCertificate,
}) { }) {
if (clientCertificate != null) { if (clientCertificate != null) {
final context = final context = SecurityContext()
SecurityContext().withClientCertificate(clientCertificate); ..usePrivateKeyBytes(
(_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = clientCertificate.bytes,
(client) => HttpClient(context: context) password: clientCertificate.passphrase,
..badCertificateCallback = )
(X509Certificate cert, String host, int port) => true; ..useCertificateChainBytes(
_securityContextStreamController.add(context); 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) { if (baseUrl != null) {
_dio.options.baseUrl = baseUrl; client.options.baseUrl = baseUrl;
} }
if (authToken != null) { 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');
}
} }

View File

@@ -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<FileServiceResponse> get(String url,
{Map<String, String>? headers}) async {
final response = await dio.get<ResponseBody>(
url,
options: Options(
headers: headers,
responseType: ResponseType.stream,
),
);
return DioGetResponse(response);
}
}
class DioGetResponse implements FileServiceResponse {
final Response<ResponseBody> _response;
final DateTime _receivedTime = DateTime.now();
DioGetResponse(this._response);
@override
Stream<List<int>> 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);
}
}

View File

@@ -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<PaperlessDocumentsApi>(),
MockSpec<PaperlessLabelsApi>(),
MockSpec<PaperlessSavedViewsApi>(),
MockSpec<PaperlessAuthenticationApi>(),
MockSpec<PaperlessServerStatsApi>(),
MockSpec<LocalVault>(),
MockSpec<EncryptedSharedPreferences>(),
MockSpec<ConnectivityStatusService>(),
MockSpec<LocalAuthentication>(),
])
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();
}

View File

@@ -1,14 +1,11 @@
import 'dart:developer' as dev;
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:paperless_api/paperless_api.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/core/widgets/highlighted_text.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.dart'; import 'package:paperless_mobile/extensions/flutter_extensions.dart';
import 'package:paperless_mobile/features/document_details/bloc/document_details_cubit.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/generated/l10n.dart';
import 'package:paperless_mobile/util.dart'; import 'package:paperless_mobile/util.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
class DocumentDetailsPage extends StatefulWidget { class DocumentDetailsPage extends StatefulWidget {

View File

@@ -58,10 +58,6 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
Iterable<int> tags = const [], Iterable<int> tags = const [],
DateTime? createdAt, DateTime? createdAt,
}) async { }) async {
final auth = await _localVault.loadAuthenticationInformation();
if (auth == null || !auth.isValid) {
throw const PaperlessServerException(ErrorCode.notAuthenticated);
}
await _documentApi.create( await _documentApi.create(
bytes, bytes,
filename: filename, filename: filename,

View File

@@ -245,8 +245,8 @@ class _DocumentUploadPreparationPageState
Navigator.pop(context, true); Navigator.pop(context, true);
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} on PaperlessValidationErrors catch (PaperlessServerExceptions) { } on PaperlessValidationErrors catch (errors) {
setState(() => _errors = PaperlessServerExceptions); setState(() => _errors = errors);
} catch (unknownError, stackTrace) { } catch (unknownError, stackTrace) {
showErrorMessage( showErrorMessage(
context, const PaperlessServerException.unknown(), stackTrace); context, const PaperlessServerException.unknown(), stackTrace);

View File

@@ -1,8 +1,8 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
part 'documents_state.dart';
class DocumentsCubit extends Cubit<DocumentsState> { class DocumentsCubit extends Cubit<DocumentsState> {
final PaperlessDocumentsApi _api; final PaperlessDocumentsApi _api;

View File

@@ -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 { class DocumentsState extends Equatable {
final bool isLoaded; final bool isLoaded;
final DocumentFilter filter; final DocumentFilter filter;
final List<PagedSearchResult> value; final List<PagedSearchResult> value;
@JsonKey(ignore: true)
final List<DocumentModel> selection; final List<DocumentModel> selection;
const DocumentsState({ const DocumentsState({

View File

@@ -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/bloc/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.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_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/documents_empty_state.dart';
import 'package:paperless_mobile/features/documents/view/widgets/grid/document_grid.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'; import 'package:paperless_mobile/features/documents/view/widgets/list/document_list.dart';
@@ -82,6 +83,8 @@ class _DocumentsPageState extends State<DocumentsPage> {
-4), //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd -4), //TODO: Wait for stable version of m3, then use AlignmentDirectional.topEnd
isLabelVisible: appliedFiltersCount > 0, isLabelVisible: appliedFiltersCount > 0,
count: state.filter.appliedFiltersCount, count: state.filter.appliedFiltersCount,
backgroundColor: Theme.of(context).colorScheme.errorContainer,
textColor: Theme.of(context).colorScheme.onErrorContainer,
child: FloatingActionButton( child: FloatingActionButton(
child: const Icon(Icons.filter_alt_outlined), child: const Icon(Icons.filter_alt_outlined),
onPressed: _openDocumentFilter, onPressed: _openDocumentFilter,

View File

@@ -10,21 +10,30 @@ class DocumentPreview extends StatelessWidget {
final BoxFit fit; final BoxFit fit;
final Alignment alignment; final Alignment alignment;
final double borderRadius; final double borderRadius;
final bool enableHero;
const DocumentPreview({ const DocumentPreview({
Key? key, super.key,
required this.id, required this.id,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.alignment = Alignment.center, this.alignment = Alignment.center,
this.borderRadius = 8.0, this.borderRadius = 8.0,
}) : super(key: key); this.enableHero = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return if (!enableHero) {
// Hero( return _buildPreview(context);
// tag: "document_$id",child: }
ClipRRect( return Hero(
tag: "thumb_$id",
child: _buildPreview(context),
);
}
ClipRRect _buildPreview(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
child: CachedNetworkImage( child: CachedNetworkImage(
fit: fit, fit: fit,
@@ -39,7 +48,6 @@ class DocumentPreview extends StatelessWidget {
), ),
cacheManager: context.watch<CacheManager>(), cacheManager: context.watch<CacheManager>(),
), ),
// ),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/empty_state.dart'; import 'package:paperless_mobile/core/widgets/empty_state.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
class DocumentsEmptyState extends StatelessWidget { class DocumentsEmptyState extends StatelessWidget {

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/documents_list_loading_widget.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_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:paperless_mobile/features/documents/view/widgets/grid/document_grid_item.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

View File

@@ -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/documents_list_loading_widget.dart';
import 'package:paperless_mobile/core/widgets/offline_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_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:paperless_mobile/features/documents/view/widgets/list/document_list_item.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:paperless_api/paperless_api.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_cubit.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
class BulkDeleteConfirmationDialog extends StatelessWidget { class BulkDeleteConfirmationDialog extends StatelessWidget {

View File

@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/widgets/offline_banner.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_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/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/features/saved_view/view/saved_view_selection_widget.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';

View File

@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.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_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/documents/view/widgets/search/sort_field_selection_bottom_sheet.dart';
import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart'; import 'package:paperless_mobile/features/labels/bloc/label_cubit.dart';

View File

@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:form_builder_validators/form_builder_validators.dart';
@@ -120,10 +121,10 @@ class _LabelFormState<T extends Label> extends State<LabelForm<T>> {
final createdLabel = await widget.submitButtonConfig final createdLabel = await widget.submitButtonConfig
.onSubmit(widget.fromJsonT(mergedJson)); .onSubmit(widget.fromJsonT(mergedJson));
Navigator.pop(context, createdLabel); Navigator.pop(context, createdLabel);
} on PaperlessValidationErrors catch (errorMessages) {
setState(() => _errors = errorMessages);
} on PaperlessServerException catch (error, stackTrace) { } on PaperlessServerException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace); showErrorMessage(context, error, stackTrace);
} on DioError catch (error) {
setState(() => _errors = error.error as PaperlessValidationErrors);
} }
} }
} }

View File

@@ -30,6 +30,7 @@ class InboxItem extends StatelessWidget {
id: document.id, id: document.id,
fit: BoxFit.cover, fit: BoxFit.cover,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
enableHero: false,
), ),
), ),
subtitle: Column( subtitle: Column(

View File

@@ -3,22 +3,19 @@ import 'dart:io';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.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/bloc/authentication_state.dart';
import 'package:paperless_mobile/features/login/model/authentication_information.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/client_certificate.dart';
import 'package:paperless_mobile/features/login/model/user_credentials.model.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/login/services/authentication_service.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
class AuthenticationCubit extends HydratedCubit<AuthenticationState> { class AuthenticationCubit extends Cubit<AuthenticationState>
with HydratedMixin<AuthenticationState> {
final LocalAuthenticationService _localAuthService; final LocalAuthenticationService _localAuthService;
final PaperlessAuthenticationApi _authApi; final PaperlessAuthenticationApi _authApi;
final LocalVault _localVault;
final AuthenticationAwareDioManager _dioWrapper; final AuthenticationAwareDioManager _dioWrapper;
AuthenticationCubit( AuthenticationCubit(
this._localVault,
this._localAuthService, this._localAuthService,
this._authApi, this._authApi,
this._dioWrapper, this._dioWrapper,
@@ -31,6 +28,7 @@ class AuthenticationCubit extends HydratedCubit<AuthenticationState> {
}) async { }) async {
assert(credentials.username != null && credentials.password != null); assert(credentials.username != null && credentials.password != null);
try { try {
print(_dioWrapper.client.hashCode);
_dioWrapper.updateSettings( _dioWrapper.updateSettings(
baseUrl: serverUrl, baseUrl: serverUrl,
clientCertificate: clientCertificate, clientCertificate: clientCertificate,
@@ -41,19 +39,22 @@ class AuthenticationCubit extends HydratedCubit<AuthenticationState> {
password: credentials.password!, password: credentials.password!,
); );
final auth = AuthenticationInformation( _dioWrapper.updateSettings(
serverUrl: serverUrl, baseUrl: serverUrl,
clientCertificate: clientCertificate, clientCertificate: clientCertificate,
token: token, authToken: token,
); );
await _localVault.storeAuthenticationInformation(auth); emit(
AuthenticationState(
emit(AuthenticationState( wasLoginStored: false,
isAuthenticated: true, authentication: AuthenticationInformation(
wasLoginStored: false, serverUrl: serverUrl,
authentication: auth, clientCertificate: clientCertificate,
)); token: token,
),
),
);
} on TlsException catch (_) { } on TlsException catch (_) {
const error = PaperlessServerException( const error = PaperlessServerException(
ErrorCode.invalidClientCertificateConfiguration); ErrorCode.invalidClientCertificateConfiguration);
@@ -67,59 +68,68 @@ class AuthenticationCubit extends HydratedCubit<AuthenticationState> {
} }
} }
Future<void> restoreSessionState() async { ///
final storedAuth = await _localVault.loadAuthenticationInformation(); /// Performs a conditional hydration based on the local authentication success.
late ApplicationSettingsState? appSettings; ///
try { Future<void> restoreSessionState(bool promptForLocalAuthentication) async {
appSettings = await _localVault.loadApplicationSettings() ?? final json = HydratedBloc.storage.read(storageToken);
ApplicationSettingsState.defaultSettings;
} catch (err) { if (json == null) {
appSettings = ApplicationSettingsState.defaultSettings; // If there is nothing to restore, we can quit here.
return;
} }
if (storedAuth == null || !storedAuth.isValid) {
return emit( if (promptForLocalAuthentication) {
AuthenticationState(isAuthenticated: false, wasLoginStored: false), final localAuthSuccess = await _localAuthService
); .authenticateLocalUser("Authenticate to log back in");
} else { if (localAuthSuccess) {
if (appSettings.isLocalAuthenticationEnabled) { hydrate();
final localAuthSuccess = await _localAuthService if (state.isAuthenticated) {
.authenticateLocalUser("Authenticate to log back in");
if (localAuthSuccess) {
_dioWrapper.updateSettings( _dioWrapper.updateSettings(
clientCertificate: storedAuth.clientCertificate, clientCertificate: state.authentication!.clientCertificate,
authToken: state.authentication!.token,
baseUrl: state.authentication!.serverUrl,
); );
return emit( return emit(
AuthenticationState( AuthenticationState(
isAuthenticated: true,
wasLoginStored: true, wasLoginStored: true,
authentication: storedAuth, authentication: state.authentication,
wasLocalAuthenticationSuccessful: true, wasLocalAuthenticationSuccessful: true,
), ),
); );
} else {
return emit(AuthenticationState(
isAuthenticated: false,
wasLoginStored: true,
wasLocalAuthenticationSuccessful: false,
));
} }
} else { } else {
hydrate();
return emit(
AuthenticationState(
wasLoginStored: true,
wasLocalAuthenticationSuccessful: false,
authentication: state.authentication,
),
);
}
} else {
hydrate();
if (state.isAuthenticated) {
_dioWrapper.updateSettings( _dioWrapper.updateSettings(
clientCertificate: storedAuth.clientCertificate, clientCertificate: state.authentication!.clientCertificate,
authToken: state.authentication!.token,
baseUrl: state.authentication!.serverUrl,
); );
final authState = AuthenticationState( final authState = AuthenticationState(
isAuthenticated: true, authentication: state.authentication!,
authentication: storedAuth,
wasLoginStored: true, wasLoginStored: true,
); );
return emit(authState); return emit(authState);
} else {
return emit(AuthenticationState.initial);
} }
} }
} }
Future<void> logout() async { Future<void> logout() async {
await _localVault.clear(); await clear();
await super.clear(); _dioWrapper.resetSettings();
emit(AuthenticationState.initial); emit(AuthenticationState.initial);
} }

View File

@@ -6,21 +6,20 @@ part 'authentication_state.g.dart';
@JsonSerializable() @JsonSerializable()
class AuthenticationState { class AuthenticationState {
final bool wasLoginStored; final bool wasLoginStored;
@JsonKey(ignore: true)
final bool? wasLocalAuthenticationSuccessful; final bool? wasLocalAuthenticationSuccessful;
final bool isAuthenticated;
final AuthenticationInformation? authentication; final AuthenticationInformation? authentication;
static final AuthenticationState initial = AuthenticationState( static final AuthenticationState initial = AuthenticationState(
wasLoginStored: false, wasLoginStored: false,
isAuthenticated: false,
); );
bool get isAuthenticated => authentication != null;
AuthenticationState({ AuthenticationState({
required this.isAuthenticated,
required this.wasLoginStored, required this.wasLoginStored,
this.wasLocalAuthenticationSuccessful, this.wasLocalAuthenticationSuccessful,
this.authentication, this.authentication,
}) : assert(!isAuthenticated || authentication != null); });
AuthenticationState copyWith({ AuthenticationState copyWith({
bool? wasLoginStored, bool? wasLoginStored,
@@ -29,7 +28,6 @@ class AuthenticationState {
bool? wasLocalAuthenticationSuccessful, bool? wasLocalAuthenticationSuccessful,
}) { }) {
return AuthenticationState( return AuthenticationState(
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
wasLoginStored: wasLoginStored ?? this.wasLoginStored, wasLoginStored: wasLoginStored ?? this.wasLoginStored,
authentication: authentication ?? this.authentication, authentication: authentication ?? this.authentication,
wasLocalAuthenticationSuccessful: wasLocalAuthenticationSuccessful ?? wasLocalAuthenticationSuccessful: wasLocalAuthenticationSuccessful ??

View File

@@ -8,10 +8,7 @@ part of 'authentication_state.dart';
AuthenticationState _$AuthenticationStateFromJson(Map<String, dynamic> json) => AuthenticationState _$AuthenticationStateFromJson(Map<String, dynamic> json) =>
AuthenticationState( AuthenticationState(
isAuthenticated: json['isAuthenticated'] as bool,
wasLoginStored: json['wasLoginStored'] as bool, wasLoginStored: json['wasLoginStored'] as bool,
wasLocalAuthenticationSuccessful:
json['wasLocalAuthenticationSuccessful'] as bool?,
authentication: json['authentication'] == null authentication: json['authentication'] == null
? null ? null
: AuthenticationInformation.fromJson( : AuthenticationInformation.fromJson(
@@ -22,8 +19,5 @@ Map<String, dynamic> _$AuthenticationStateToJson(
AuthenticationState instance) => AuthenticationState instance) =>
<String, dynamic>{ <String, dynamic>{
'wasLoginStored': instance.wasLoginStored, 'wasLoginStored': instance.wasLoginStored,
'wasLocalAuthenticationSuccessful':
instance.wasLocalAuthenticationSuccessful,
'isAuthenticated': instance.isAuthenticated,
'authentication': instance.authentication, 'authentication': instance.authentication,
}; };

View File

@@ -49,7 +49,7 @@ class _LoginPageState extends State<LoginPage> {
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: Text( child: Text(
S.of(context).loginPageAdvancedLabel, S.of(context).loginPageAdvancedLabel,
style: Theme.of(context).textTheme.bodyText1, style: Theme.of(context).textTheme.bodyLarge,
).padded(), ).padded(),
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:paperless_mobile/core/service/connectivity_status.service.dart'; import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:paperless_mobile/generated/l10n.dart'; import 'package:paperless_mobile/generated/l10n.dart';
@@ -26,6 +27,10 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
validator: FormBuilderValidators.required( validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageServerUrlValidatorMessageText, errorText: S.of(context).loginPageServerUrlValidatorMessageText,
), ),
inputFormatters: [
FilteringTextInputFormatter.deny(r".*/$"),
FilteringTextInputFormatter.deny(r"\s"),
],
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: _buildIsReachableIcon(), suffixIcon: _buildIsReachableIcon(),
hintText: "http://192.168.1.50:8000", hintText: "http://192.168.1.50:8000",

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.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_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/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_cubit.dart';
import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart'; import 'package:paperless_mobile/features/saved_view/cubit/saved_view_state.dart';

View File

@@ -1,20 +1,30 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.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/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart'; import 'package:paperless_mobile/features/settings/model/view_type.dart';
class ApplicationSettingsCubit extends HydratedCubit<ApplicationSettingsState> { class ApplicationSettingsCubit extends HydratedCubit<ApplicationSettingsState> {
ApplicationSettingsCubit() : super(ApplicationSettingsState.defaultSettings); final LocalAuthenticationService _localAuthenticationService;
ApplicationSettingsCubit(this._localAuthenticationService)
: super(ApplicationSettingsState.defaultSettings);
Future<void> setLocale(String? localeSubtag) async { Future<void> setLocale(String? localeSubtag) async {
final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag); final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag);
_updateSettings(updatedSettings); _updateSettings(updatedSettings);
} }
Future<void> setIsBiometricAuthenticationEnabled(bool isEnabled) async { Future<void> setIsBiometricAuthenticationEnabled(
final updatedSettings = bool isEnabled, {
state.copyWith(isLocalAuthenticationEnabled: isEnabled); required String localizedReason,
_updateSettings(updatedSettings); }) async {
final isActionAuthorized = await _localAuthenticationService
.authenticateLocalUser(localizedReason);
if (isActionAuthorized) {
final updatedSettings =
state.copyWith(isLocalAuthenticationEnabled: isEnabled);
_updateSettings(updatedSettings);
}
} }
Future<void> setThemeMode(ThemeMode? selectedMode) async { Future<void> setThemeMode(ThemeMode? selectedMode) async {

View File

@@ -19,7 +19,6 @@ class BiometricAuthenticationSetting extends StatelessWidget {
subtitle: Text( subtitle: Text(
S.of(context).appSettingsBiometricAuthenticationDescriptionText), S.of(context).appSettingsBiometricAuthenticationDescriptionText),
onChanged: (val) async { onChanged: (val) async {
final settingsBloc = context.read<ApplicationSettingsCubit>();
final String localizedReason = val final String localizedReason = val
? S ? S
.of(context) .of(context)
@@ -27,12 +26,10 @@ class BiometricAuthenticationSetting extends StatelessWidget {
: S : S
.of(context) .of(context)
.appSettingsDisableBiometricAuthenticationReasonText; .appSettingsDisableBiometricAuthenticationReasonText;
final changeValue = await context await context
.read<LocalAuthenticationService>() .read<ApplicationSettingsCubit>()
.authenticateLocalUser(localizedReason); .setIsBiometricAuthenticationEnabled(val,
if (changeValue) { localizedReason: localizedReason);
settingsBloc.setIsBiometricAuthenticationEnabled(val);
}
}, },
); );
}, },

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.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:flutter_native_splash/flutter_native_splash.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:form_builder_validators/form_builder_validators.dart';
import 'package:http/http.dart'; import 'package:hive/hive.dart';
import 'package:http/io_client.dart';
import 'package:http_interceptor/http/intercepted_client.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl_standalone.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/connectivity_cubit.dart';
import 'package:paperless_mobile/core/bloc/paperless_server_information_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/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/logic/error_code_localization_mapper.dart';
import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart'; import 'package:paperless_mobile/core/repository/impl/correspondent_repository_impl.dart';
import 'package:paperless_mobile/core/repository/impl/document_type_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/repository/saved_view_repository.dart';
import 'package:paperless_mobile/core/security/authentication_aware_dio_manager.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/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/service/file_service.dart';
import 'package:paperless_mobile/core/store/local_vault.dart'; import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/extensions/flutter_extensions.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/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/cubit/document_upload_cubit.dart';
import 'package:paperless_mobile/features/document_upload/view/document_upload_preparation_page.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/home/view/home_page.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.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/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/services/authentication_service.dart';
import 'package:paperless_mobile/features/login/view/login_page.dart'; import 'package:paperless_mobile/features/login/view/login_page.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.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:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
void main() async { void main() async {
Bloc.observer = BlocChangesObserver(); Bloc.observer = BlocChangesObserver();
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
await findSystemLocale(); await findSystemLocale();
// Required for self signed client certificates
final dioWrapper = AuthenticationAwareDioManager();
// Initialize External dependencies // Initialize External dependencies
final connectivity = Connectivity(); final connectivity = Connectivity();
final encryptedSharedPreferences = EncryptedSharedPreferences(); final encryptedSharedPreferences = EncryptedSharedPreferences();
final localAuthentication = LocalAuthentication(); 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 // Initialize Paperless APIs
final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client); final authApi = PaperlessAuthenticationApiImpl(dioWrapper.client);
@@ -79,14 +103,6 @@ void main() async {
final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client); final statsApi = PaperlessServerStatsApiImpl(dioWrapper.client);
final savedViewsApi = PaperlessSavedViewsApiImpl(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 // Initialize Blocs/Cubits
final connectivityCubit = ConnectivityCubit(connectivityStatusService); final connectivityCubit = ConnectivityCubit(connectivityStatusService);
// Remove temporarily downloaded files. // Remove temporarily downloaded files.
@@ -95,15 +111,21 @@ void main() async {
// Load application settings and stored authentication data // Load application settings and stored authentication data
await connectivityCubit.initialize(); 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( final authCubit = AuthenticationCubit(
localVault,
localAuthService, localAuthService,
authApi, authApi,
dioWrapper, dioWrapper,
); );
await authCubit
String? currentServerUrl; .restoreSessionState(appSettingsCubit.state.isLocalAuthenticationEnabled);
String? currentAuthToken;
if (authCubit.state.isAuthenticated) { if (authCubit.state.isAuthenticated) {
final auth = authCubit.state.authentication!; final auth = authCubit.state.authentication!;
@@ -112,31 +134,11 @@ void main() async {
authToken: auth.token, authToken: auth.token,
clientCertificate: auth.clientCertificate, clientCertificate: auth.clientCertificate,
); );
currentServerUrl = auth.serverUrl;
currentAuthToken = auth.token;
} }
SecurityContext securityContext = SecurityContext(); //Update language header in interceptor on language change.
authCubit.stream.asBroadcastStream().listen((event) { appSettingsCubit.stream.listen((event) => languageHeaderInterceptor
if (event.isAuthenticated) { .preferredLocaleSubtag = event.preferredLocaleSubtag);
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);
runApp( runApp(
MultiProvider( MultiProvider(
@@ -146,44 +148,11 @@ void main() async {
Provider<PaperlessLabelsApi>.value(value: labelsApi), Provider<PaperlessLabelsApi>.value(value: labelsApi),
Provider<PaperlessServerStatsApi>.value(value: statsApi), Provider<PaperlessServerStatsApi>.value(value: statsApi),
Provider<PaperlessSavedViewsApi>.value(value: savedViewsApi), Provider<PaperlessSavedViewsApi>.value(value: savedViewsApi),
ProxyProvider<SecurityContext, cm.CacheManager>( Provider<cm.CacheManager>(
create: (context) => cm.CacheManager( create: (context) => cm.CacheManager(
cm.Config( cm.Config(
'cacheKey', 'cacheKey',
fileService: cm.HttpFileService( fileService: DioFileService(dioWrapper.client),
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,
),
),
)),
), ),
), ),
), ),
@@ -214,6 +183,8 @@ void main() async {
providers: [ providers: [
BlocProvider<AuthenticationCubit>.value(value: authCubit), BlocProvider<AuthenticationCubit>.value(value: authCubit),
BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), BlocProvider<ConnectivityCubit>.value(value: connectivityCubit),
BlocProvider<ApplicationSettingsCubit>.value(
value: appSettingsCubit),
], ],
child: const PaperlessMobileEntrypoint(), child: const PaperlessMobileEntrypoint(),
), ),
@@ -282,9 +253,6 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
BlocProvider( BlocProvider(
create: (context) => PaperlessServerInformationCubit(context.read()), create: (context) => PaperlessServerInformationCubit(context.read()),
), ),
BlocProvider(
create: (context) => ApplicationSettingsCubit(),
),
], ],
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>( child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
builder: (context, settings) { builder: (context, settings) {
@@ -423,7 +391,8 @@ class _AuthenticationWrapperState extends State<AuthenticationWrapper> {
} }
}, },
builder: (context, authentication) { builder: (context, authentication) {
if (authentication.isAuthenticated) { if (authentication.isAuthenticated &&
(authentication.wasLocalAuthenticationSuccessful ?? true)) {
return const HomePage(); return const HomePage();
} else { } else {
if (authentication.wasLoginStored && if (authentication.wasLoginStored &&
@@ -461,12 +430,20 @@ class BiometricAuthenticationPage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
ElevatedButton( ElevatedButton(
onPressed: () => context.read<AuthenticationCubit>().logout(), onPressed: () {
context.read<AuthenticationCubit>().logout();
context.read();
HydratedBloc.storage.clear();
},
child: const Text("Log out"), child: const Text("Log out"),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => onPressed: () => context
context.read<AuthenticationCubit>().restoreSessionState(), .read<AuthenticationCubit>()
.restoreSessionState(context
.read<ApplicationSettingsCubit>()
.state
.isLocalAuthenticationEnabled),
child: const Text("Authenticate"), child: const Text("Authenticate"),
), ),
], ],

View File

@@ -0,0 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/src/models/models.dart';
class IdQueryParameterJsonConverter
extends JsonConverter<IdQueryParameter, Map<String, dynamic>> {
const IdQueryParameterJsonConverter();
static const _idKey = "id";
static const _assignmentStatusKey = 'assignmentStatus';
@override
IdQueryParameter fromJson(Map<String, dynamic> json) {
return IdQueryParameter(json[_assignmentStatusKey], json[_idKey]);
}
@override
Map<String, dynamic> toJson(IdQueryParameter object) {
return {
_idKey: object.id,
_assignmentStatusKey: object.assignmentStatus,
};
}
}

View File

@@ -1,6 +1,8 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/paperless_api.dart'; import 'package:paperless_api/paperless_api.dart';
@JsonSerializable()
class DocumentFilter extends Equatable { class DocumentFilter extends Equatable {
static const _oneDay = Duration(days: 1); static const _oneDay = Duration(days: 1);
static const DocumentFilter initial = DocumentFilter(); static const DocumentFilter initial = DocumentFilter();

View File

@@ -1,8 +1,10 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/src/models/document_model.dart'; import 'package:paperless_api/src/models/document_model.dart';
const pageRegex = r".*page=(\d+).*"; const pageRegex = r".*page=(\d+).*";
//Todo: make this an interface and delegate serialization to implementations
class PagedSearchResultJsonSerializer<T> { class PagedSearchResultJsonSerializer<T> {
final Map<String, dynamic> json; final Map<String, dynamic> json;
final T Function(Map<String, dynamic>) fromJson; final T Function(Map<String, dynamic>) fromJson;
@@ -10,6 +12,7 @@ class PagedSearchResultJsonSerializer<T> {
PagedSearchResultJsonSerializer(this.json, this.fromJson); PagedSearchResultJsonSerializer(this.json, this.fromJson);
} }
@JsonSerializable()
class PagedSearchResult<T> extends Equatable { class PagedSearchResult<T> extends Equatable {
/// Total number of available items /// Total number of available items
final int count; final int count;

View File

@@ -1,9 +1,17 @@
import 'package:equatable/equatable.dart'; 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 { class IdQueryParameter extends Equatable {
final int? _assignmentStatus; final int? _assignmentStatus;
final int? _id; final int? _id;
@Deprecated("Use named constructors, this is only meant for code generation")
const IdQueryParameter(this._assignmentStatus, this._id);
const IdQueryParameter.notAssigned() const IdQueryParameter.notAssigned()
: _assignmentStatus = 1, : _assignmentStatus = 1,
_id = null; _id = null;
@@ -28,6 +36,9 @@ class IdQueryParameter extends Equatable {
int? get id => _id; int? get id => _id;
@visibleForTesting
int? get assignmentStatus => _assignmentStatus;
Map<String, String> toQueryParameter(String field) { Map<String, String> toQueryParameter(String field) {
final Map<String, String> params = {}; final Map<String, String> params = {};
if (onlyNotAssigned || onlyAssigned) { if (onlyNotAssigned || onlyAssigned) {

View File

@@ -12,6 +12,7 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
required String username, required String username,
required String password, required String password,
}) async { }) async {
print(client.hashCode);
late Response response; late Response response;
try { try {
response = await client.post( response = await client.post(
@@ -21,27 +22,19 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
"password": password, "password": password,
}, },
); );
} on FormatException catch (e) { } on DioError catch (error) {
final source = e.source; if (error.error is ErrorCode) {
if (source is String &&
source.contains("400 No required SSL certificate was sent")) {
throw PaperlessServerException( throw PaperlessServerException(
ErrorCode.missingClientCertificate, error.error,
httpStatusCode: response.statusCode, httpStatusCode: error.response?.statusCode,
); );
} else {
throw error.error;
} }
} }
if (response.statusCode == 200) { if (response.statusCode == 200) {
return response.data['token']; 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 { } else {
throw PaperlessServerException( throw PaperlessServerException(
ErrorCode.authenticationFailed, ErrorCode.authenticationFailed,

View File

@@ -15,12 +15,19 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
Uint8List documentBytes, { Uint8List documentBytes, {
required String filename, required String filename,
required String title, required String title,
String contentType = 'application/octet-stream',
DateTime? createdAt, DateTime? createdAt,
int? documentType, int? documentType,
int? correspondent, int? correspondent,
Iterable<int> tags = const [], Iterable<int> tags = const [],
}) async { }) async {
final formData = FormData(); final formData = FormData()
..files.add(
MapEntry(
'document',
MultipartFile.fromBytes(documentBytes, filename: filename),
),
);
formData.fields.add(MapEntry('title', title)); formData.fields.add(MapEntry('title', title));
if (createdAt != null) { if (createdAt != null) {
@@ -35,6 +42,7 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
for (final tag in tags) { for (final tag in tags) {
formData.fields.add(MapEntry('tags', tag.toString())); formData.fields.add(MapEntry('tags', tag.toString()));
} }
final response = final response =
await client.post('/api/documents/post_document/', data: formData); await client.post('/api/documents/post_document/', data: formData);
if (response.statusCode != 200) { if (response.statusCode != 200) {

View File

@@ -1236,6 +1236,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" 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: process:
dependency: transitive dependency: transitive
description: description:

View File

@@ -81,6 +81,7 @@ dependencies:
dio: ^4.0.6 dio: ^4.0.6
hydrated_bloc: ^9.0.0 hydrated_bloc: ^9.0.0
json_annotation: ^4.7.0 json_annotation: ^4.7.0
pretty_dio_logger: ^1.2.0-beta-1
dev_dependencies: dev_dependencies:

View File

@@ -4,6 +4,7 @@ import 'package:paperless_mobile/features/documents/bloc/documents_cubit.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'package:paperless_mobile/features/documents/bloc/documents_state.dart';
import '../../utils.dart'; import '../../utils.dart';
@GenerateNiceMocks([MockSpec<PaperlessDocumentsApi>()]) @GenerateNiceMocks([MockSpec<PaperlessDocumentsApi>()])
@@ -23,6 +24,7 @@ void main() async {
await loadCollection("test/fixtures/correspondents/correspondents.json", await loadCollection("test/fixtures/correspondents/correspondents.json",
Correspondent.fromJson), Correspondent.fromJson),
); );
final List<DocumentType> documentTypes = List.unmodifiable( final List<DocumentType> documentTypes = List.unmodifiable(
await loadCollection("test/fixtures/document_types/document_types.json", await loadCollection("test/fixtures/document_types/document_types.json",
DocumentType.fromJson), DocumentType.fromJson),