mirror of
https://github.com/Xevion/paperless-mobile.git
synced 2025-12-08 06:07:48 -06:00
Fixed login and error handling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String>().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<String, dynamic>) {
|
||||
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<String, dynamic> 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<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 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BaseRequest> 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<BaseResponse> interceptResponse(
|
||||
{required BaseResponse response}) async =>
|
||||
response;
|
||||
|
||||
@override
|
||||
Future<bool> shouldInterceptRequest() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> shouldInterceptResponse() async => true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Interceptor> 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<SecurityContext> get securityContextChanges =>
|
||||
_securityContextStreamController.stream.asBroadcastStream();
|
||||
|
||||
final StreamController<SecurityContext> _securityContextStreamController =
|
||||
StreamController.broadcast();
|
||||
|
||||
static Dio _initDio() {
|
||||
static Dio _initDio(List<Interceptor> 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');
|
||||
}
|
||||
}
|
||||
|
||||
82
lib/core/service/dio_file_service.dart
Normal file
82
lib/core/service/dio_file_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,10 +58,6 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
Iterable<int> 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<DocumentsState> {
|
||||
final PaperlessDocumentsApi _api;
|
||||
|
||||
@@ -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<PagedSearchResult> value;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
final List<DocumentModel> selection;
|
||||
|
||||
const DocumentsState({
|
||||
|
||||
@@ -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<DocumentsPage> {
|
||||
-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,
|
||||
|
||||
@@ -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<CacheManager>(),
|
||||
),
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<T extends Label> extends State<LabelForm<T>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class InboxItem extends StatelessWidget {
|
||||
id: document.id,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
enableHero: false,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
|
||||
@@ -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<AuthenticationState> {
|
||||
class AuthenticationCubit extends Cubit<AuthenticationState>
|
||||
with HydratedMixin<AuthenticationState> {
|
||||
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<AuthenticationState> {
|
||||
}) 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<AuthenticationState> {
|
||||
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<AuthenticationState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> logout() async {
|
||||
await _localVault.clear();
|
||||
await super.clear();
|
||||
await clear();
|
||||
_dioWrapper.resetSettings();
|
||||
emit(AuthenticationState.initial);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -8,10 +8,7 @@ part of 'authentication_state.dart';
|
||||
|
||||
AuthenticationState _$AuthenticationStateFromJson(Map<String, dynamic> 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<String, dynamic> _$AuthenticationStateToJson(
|
||||
AuthenticationState instance) =>
|
||||
<String, dynamic>{
|
||||
'wasLoginStored': instance.wasLoginStored,
|
||||
'wasLocalAuthenticationSuccessful':
|
||||
instance.wasLocalAuthenticationSuccessful,
|
||||
'isAuthenticated': instance.isAuthenticated,
|
||||
'authentication': instance.authentication,
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<ServerAddressFormField> {
|
||||
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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ApplicationSettingsState> {
|
||||
ApplicationSettingsCubit() : super(ApplicationSettingsState.defaultSettings);
|
||||
final LocalAuthenticationService _localAuthenticationService;
|
||||
ApplicationSettingsCubit(this._localAuthenticationService)
|
||||
: super(ApplicationSettingsState.defaultSettings);
|
||||
|
||||
Future<void> setLocale(String? localeSubtag) async {
|
||||
final updatedSettings = state.copyWith(preferredLocaleSubtag: localeSubtag);
|
||||
_updateSettings(updatedSettings);
|
||||
}
|
||||
|
||||
Future<void> setIsBiometricAuthenticationEnabled(bool isEnabled) async {
|
||||
final updatedSettings =
|
||||
state.copyWith(isLocalAuthenticationEnabled: isEnabled);
|
||||
_updateSettings(updatedSettings);
|
||||
Future<void> setIsBiometricAuthenticationEnabled(
|
||||
bool isEnabled, {
|
||||
required String localizedReason,
|
||||
}) async {
|
||||
final isActionAuthorized = await _localAuthenticationService
|
||||
.authenticateLocalUser(localizedReason);
|
||||
if (isActionAuthorized) {
|
||||
final updatedSettings =
|
||||
state.copyWith(isLocalAuthenticationEnabled: isEnabled);
|
||||
_updateSettings(updatedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode? selectedMode) async {
|
||||
|
||||
@@ -19,7 +19,6 @@ class BiometricAuthenticationSetting extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
S.of(context).appSettingsBiometricAuthenticationDescriptionText),
|
||||
onChanged: (val) async {
|
||||
final settingsBloc = context.read<ApplicationSettingsCubit>();
|
||||
final String localizedReason = val
|
||||
? S
|
||||
.of(context)
|
||||
@@ -27,12 +26,10 @@ class BiometricAuthenticationSetting extends StatelessWidget {
|
||||
: S
|
||||
.of(context)
|
||||
.appSettingsDisableBiometricAuthenticationReasonText;
|
||||
final changeValue = await context
|
||||
.read<LocalAuthenticationService>()
|
||||
.authenticateLocalUser(localizedReason);
|
||||
if (changeValue) {
|
||||
settingsBloc.setIsBiometricAuthenticationEnabled(val);
|
||||
}
|
||||
await context
|
||||
.read<ApplicationSettingsCubit>()
|
||||
.setIsBiometricAuthenticationEnabled(val,
|
||||
localizedReason: localizedReason);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
157
lib/main.dart
157
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<PaperlessLabelsApi>.value(value: labelsApi),
|
||||
Provider<PaperlessServerStatsApi>.value(value: statsApi),
|
||||
Provider<PaperlessSavedViewsApi>.value(value: savedViewsApi),
|
||||
ProxyProvider<SecurityContext, cm.CacheManager>(
|
||||
Provider<cm.CacheManager>(
|
||||
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<AuthenticationCubit>.value(value: authCubit),
|
||||
BlocProvider<ConnectivityCubit>.value(value: connectivityCubit),
|
||||
BlocProvider<ApplicationSettingsCubit>.value(
|
||||
value: appSettingsCubit),
|
||||
],
|
||||
child: const PaperlessMobileEntrypoint(),
|
||||
),
|
||||
@@ -282,9 +253,6 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
|
||||
BlocProvider(
|
||||
create: (context) => PaperlessServerInformationCubit(context.read()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => ApplicationSettingsCubit(),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<ApplicationSettingsCubit, ApplicationSettingsState>(
|
||||
builder: (context, settings) {
|
||||
@@ -423,7 +391,8 @@ class _AuthenticationWrapperState extends State<AuthenticationWrapper> {
|
||||
}
|
||||
},
|
||||
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<AuthenticationCubit>().logout(),
|
||||
onPressed: () {
|
||||
context.read<AuthenticationCubit>().logout();
|
||||
context.read();
|
||||
HydratedBloc.storage.clear();
|
||||
},
|
||||
child: const Text("Log out"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
context.read<AuthenticationCubit>().restoreSessionState(),
|
||||
onPressed: () => context
|
||||
.read<AuthenticationCubit>()
|
||||
.restoreSessionState(context
|
||||
.read<ApplicationSettingsCubit>()
|
||||
.state
|
||||
.isLocalAuthenticationEnabled),
|
||||
child: const Text("Authenticate"),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T> {
|
||||
final Map<String, dynamic> json;
|
||||
final T Function(Map<String, dynamic>) fromJson;
|
||||
@@ -10,6 +12,7 @@ class PagedSearchResultJsonSerializer<T> {
|
||||
PagedSearchResultJsonSerializer(this.json, this.fromJson);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class PagedSearchResult<T> extends Equatable {
|
||||
/// Total number of available items
|
||||
final int count;
|
||||
|
||||
@@ -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<String, String> toQueryParameter(String field) {
|
||||
final Map<String, String> params = {};
|
||||
if (onlyNotAssigned || onlyAssigned) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<int> 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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<PaperlessDocumentsApi>()])
|
||||
@@ -23,6 +24,7 @@ void main() async {
|
||||
await loadCollection("test/fixtures/correspondents/correspondents.json",
|
||||
Correspondent.fromJson),
|
||||
);
|
||||
|
||||
final List<DocumentType> documentTypes = List.unmodifiable(
|
||||
await loadCollection("test/fixtures/document_types/document_types.json",
|
||||
DocumentType.fromJson),
|
||||
|
||||
Reference in New Issue
Block a user