From 497777c52b41703188446d0ed296fa4f651b396e Mon Sep 17 00:00:00 2001 From: Anton Stubenbord Date: Sat, 6 Jan 2024 19:23:30 +0100 Subject: [PATCH] feat: Add login integration test (WIP), update notes feature --- .../android/de-DE/changelogs/4043.txt | 2 + build.yaml | 16 + integration_test/login_integration_test.dart | 316 ++++++------------ integration_test/src/framework.dart | 17 +- .../src/mocks/mock_paperless_api.dart | 65 ++++ .../database/hive/hive_initialization.dart | 25 ++ .../language_header.interceptor.dart | 8 +- lib/core/security/session_manager.dart | 87 +---- lib/core/security/session_manager_impl.dart | 96 ++++++ .../service/connectivity_status_service.dart | 3 +- .../view/widgets/document_notes_widget.dart | 2 + .../login/cubit/authentication_cubit.dart | 15 +- lib/features/login/view/add_account_page.dart | 3 +- .../server_address_form_field.dart | 3 +- lib/keys.dart | 19 ++ lib/main.dart | 172 +++++----- lib/routing/routes/login_route.dart | 2 + lib/theme.dart | 4 +- .../lib/src/models/document_model.dart | 2 +- .../example/pubspec.lock | 26 +- 20 files changed, 465 insertions(+), 418 deletions(-) create mode 100644 android/fastlane/metadata/android/de-DE/changelogs/4043.txt create mode 100644 build.yaml create mode 100644 integration_test/src/mocks/mock_paperless_api.dart create mode 100644 lib/core/database/hive/hive_initialization.dart create mode 100644 lib/core/security/session_manager_impl.dart create mode 100644 lib/keys.dart diff --git a/android/fastlane/metadata/android/de-DE/changelogs/4043.txt b/android/fastlane/metadata/android/de-DE/changelogs/4043.txt new file mode 100644 index 0000000..f591cc4 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/changelogs/4043.txt @@ -0,0 +1,2 @@ +- New feature: Notes +- Several bugfixes diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..57dfbd7 --- /dev/null +++ b/build.yaml @@ -0,0 +1,16 @@ +targets: + $default: + include: + - pubspec.yaml + sources: + - assets/** + - lib/$lib$ + - lib/**.dart + - test/**.dart + - integration_test/**.dart + + builders: + mockito|mockBuilder: + generate_for: + - test/**.dart + - integration_test/**.dart \ No newline at end of file diff --git a/integration_test/login_integration_test.dart b/integration_test/login_integration_test.dart index 8f59b03..285d47b 100644 --- a/integration_test/login_integration_test.dart +++ b/integration_test/login_integration_test.dart @@ -1,224 +1,130 @@ -// import 'package:flutter/material.dart'; -// import 'package:flutter_test/flutter_test.dart'; -// import 'package:mockito/mockito.dart'; -// import 'package:paperless_api/paperless_api.dart'; -// import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; -// import 'package:paperless_mobile/core/service/connectivity_status.service.dart'; -// import 'package:paperless_mobile/core/store/local_vault.dart'; -// import 'package:paperless_mobile/di_test_mocks.mocks.dart'; -// import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart'; -// import 'package:paperless_mobile/features/settings/model/application_settings_state.dart'; -// import 'package:paperless_mobile/features/settings/model/view_type.dart'; +import 'dart:io'; -// import 'src/framework.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_initialization.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; +import 'package:paperless_mobile/keys.dart'; +import 'package:paperless_mobile/main.dart' + show initializeDefaultParameters, AppEntrypoint; +import 'package:path_provider/path_provider.dart'; -// void main() async { -// final t = await initializeTestingFramework(languageCode: 'de'); +import 'src/mocks/mock_paperless_api.dart'; -// const testServerUrl = 'https://example.com'; -// const testUsername = 'user'; -// const testPassword = 'pass'; +class MockConnectivityStatusService extends Mock + implements ConnectivityStatusService {} -// final serverAddressField = find.byKey(const ValueKey('login-server-address')); -// final usernameField = find.byKey(const ValueKey('login-username')); -// final passwordField = find.byKey(const ValueKey('login-password')); -// final loginBtn = find.byKey(const ValueKey('login-login-button')); +class MockLocalAuthService extends Mock implements LocalAuthenticationService {} -// testWidgets('Test successful login flow', (WidgetTester tester) async { -// await initAndLaunchTestApp(tester, () async { -// // Initialize dat for mocked classes -// when((getIt()).connectivityChanges()) -// .thenAnswer((i) => Stream.value(true)); -// when((getIt() as MockLocalVault) -// .loadAuthenticationInformation()) -// .thenAnswer((realInvocation) async => null); -// when((getIt() as MockLocalVault).loadApplicationSettings()) -// .thenAnswer((realInvocation) async => ApplicationSettingsState( -// preferredLocaleSubtag: 'en', -// preferredThemeMode: ThemeMode.light, -// isLocalAuthenticationEnabled: false, -// preferredViewType: ViewType.list, -// showInboxOnStartup: false, -// )); -// when(getIt().login( -// username: testUsername, -// password: testPassword, -// )).thenAnswer((i) => Future.value("eyTestToken")); +class MockSessionManager extends Mock implements SessionManager {} -// await getIt().initialize(); -// await getIt().initialize(); -// }); +class MockLocalNotificationService extends Mock + implements LocalNotificationService {} -// // Mocked classes +void main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const locale = Locale("en", "US"); + const testServerUrl = 'https://example.com'; + const testUsername = 'user'; + const testPassword = 'pass'; -// await t.binding.waitUntilFirstFrameRasterized; -// await tester.pumpAndSettle(); + final hiveDirectory = await getTemporaryDirectory(); -// await tester.enterText(serverAddressField, testServerUrl); -// await tester.pumpAndSettle(); + late ConnectivityStatusService connectivityStatusService; + late MockPaperlessApiFactory paperlessApiFactory; + late AuthenticationCubit authenticationCubit; + late LocalNotificationService localNotificationService; + late SessionManager sessionManager; + final localAuthService = MockLocalAuthService(); -// await tester.enterText(usernameField, testUsername); -// await tester.pumpAndSettle(); + setUp(() async { + connectivityStatusService = MockConnectivityStatusService(); + paperlessApiFactory = MockPaperlessApiFactory(); + sessionManager = MockSessionManager(); + localNotificationService = MockLocalNotificationService(); -// await tester.enterText(passwordField, testPassword); + authenticationCubit = AuthenticationCubit( + localAuthService, + paperlessApiFactory, + sessionManager, + connectivityStatusService, + localNotificationService, + ); + await initHive( + hiveDirectory, + locale.toString(), + ); + }); + testWidgets( + 'A user shall be successfully logged in when providing correct credentials.', + (tester) async { + // Reset data to initial state with given [locale]. + await Hive.globalSettingsBox.setValue( + GlobalSettings( + preferredLocaleSubtag: locale.toString(), + loggedInUserId: null, + ), + ); + when(paperlessApiFactory.authenticationApi.login( + username: testUsername, + password: testPassword, + )).thenAnswer((_) async => "token"); -// FocusManager.instance.primaryFocus?.unfocus(); -// await tester.pumpAndSettle(); + await initializeDefaultParameters(); -// await tester.tap(loginBtn); + await tester.pumpWidget( + AppEntrypoint( + apiFactory: paperlessApiFactory, + authenticationCubit: authenticationCubit, + connectivityStatusService: connectivityStatusService, + localNotificationService: localNotificationService, + localAuthService: localAuthService, + sessionManager: sessionManager, + ), + ); + await tester.binding.waitUntilFirstFrameRasterized; + await tester.pumpAndSettle(); -// verify(getIt().login( -// username: testUsername, -// password: testPassword, -// )).called(1); -// }); + await tester.enterText( + find.byKey(TestKeys.login.serverAddressFormField), + testServerUrl, + ); + await tester.pumpAndSettle(); -// testWidgets('Test login validation missing password', -// (WidgetTester tester) async { -// await initAndLaunchTestApp(tester, () async { -// when((getIt() as MockConnectivityStatusService) -// .connectivityChanges()) -// .thenAnswer((i) => Stream.value(true)); -// when((getIt() as MockLocalVault) -// .loadAuthenticationInformation()) -// .thenAnswer((realInvocation) async => null); + await tester.press(find.byKey(TestKeys.login.continueButton)); -// when((getIt() as MockLocalVault).loadApplicationSettings()) -// .thenAnswer((realInvocation) async => ApplicationSettingsState( -// preferredLocaleSubtag: 'en', -// preferredThemeMode: ThemeMode.light, -// isLocalAuthenticationEnabled: false, -// preferredViewType: ViewType.list, -// showInboxOnStartup: false, -// )); + await tester.pumpAndSettle(); + expect( + find.byKey(TestKeys.login.usernameFormField), + findsOneWidget, + ); -// await getIt().initialize(); -// await getIt().initialize(); -// }); -// // Mocked classes + await tester.enterText( + find.byKey(TestKeys.login.usernameFormField), + testUsername, + ); + await tester.enterText( + find.byKey(TestKeys.login.passwordFormField), + testUsername, + ); + await tester.pumpAndSettle(); -// // Initialize dat for mocked classes + await tester.press(find.byKey(TestKeys.login.loginButton)); + await tester.pumpAndSettle(); -// await t.binding.waitUntilFirstFrameRasterized; -// await tester.pumpAndSettle(); - -// await tester.enterText(serverAddressField, testServerUrl); -// await tester.pumpAndSettle(); - -// await tester.enterText(usernameField, testUsername); -// await tester.pumpAndSettle(); - -// FocusManager.instance.primaryFocus?.unfocus(); -// await tester.pumpAndSettle(); - -// await tester.tap(loginBtn); -// await tester.pumpAndSettle(); - -// verifyNever( -// (getIt() as MockPaperlessAuthenticationApi) -// .login( -// username: testUsername, -// password: testPassword, -// )); -// expect( -// find.textContaining(t.translations.passwordMustNotBeEmpty), -// findsOneWidget, -// ); -// }); - -// testWidgets('Test login validation missing username', -// (WidgetTester tester) async { -// await initAndLaunchTestApp(tester, () async { -// when((getIt() as MockConnectivityStatusService) -// .connectivityChanges()) -// .thenAnswer((i) => Stream.value(true)); -// when((getIt() as MockLocalVault) -// .loadAuthenticationInformation()) -// .thenAnswer((realInvocation) async => null); -// when((getIt() as MockLocalVault).loadApplicationSettings()) -// .thenAnswer((realInvocation) async => ApplicationSettingsState( -// preferredLocaleSubtag: 'en', -// preferredThemeMode: ThemeMode.light, -// isLocalAuthenticationEnabled: false, -// preferredViewType: ViewType.list, -// showInboxOnStartup: false, -// )); -// await getIt().initialize(); -// await getIt().initialize(); -// }); - -// await t.binding.waitUntilFirstFrameRasterized; -// await tester.pumpAndSettle(); - -// await tester.enterText(serverAddressField, testServerUrl); -// await tester.pumpAndSettle(); - -// await tester.enterText(passwordField, testPassword); -// await tester.pumpAndSettle(); - -// FocusManager.instance.primaryFocus?.unfocus(); -// await tester.pumpAndSettle(); - -// await tester.tap(loginBtn); -// await tester.pumpAndSettle(); - -// verifyNever( -// (getIt() as MockPaperlessAuthenticationApi) -// .login( -// username: testUsername, -// password: testPassword, -// )); -// expect( -// find.textContaining(t.translations.usernameMustNotBeEmpty), -// findsOneWidget, -// ); -// }); - -// testWidgets('Test login validation missing server address', -// (WidgetTester tester) async { -// initAndLaunchTestApp(tester, () async { -// when((getIt()).connectivityChanges()) -// .thenAnswer((i) => Stream.value(true)); - -// when((getIt()).loadAuthenticationInformation()) -// .thenAnswer((realInvocation) async => null); - -// when((getIt()).loadApplicationSettings()) -// .thenAnswer((realInvocation) async => ApplicationSettingsState( -// preferredLocaleSubtag: 'en', -// preferredThemeMode: ThemeMode.light, -// isLocalAuthenticationEnabled: false, -// preferredViewType: ViewType.list, -// showInboxOnStartup: false, -// )); - -// await getIt().initialize(); -// await getIt().initialize(); -// }); - -// await t.binding.waitUntilFirstFrameRasterized; -// await tester.pumpAndSettle(); - -// await tester.enterText(usernameField, testUsername); -// await tester.pumpAndSettle(); - -// await tester.enterText(passwordField, testPassword); -// await tester.pumpAndSettle(); - -// FocusManager.instance.primaryFocus?.unfocus(); -// await tester.pumpAndSettle(); - -// await tester.tap(loginBtn); -// await tester.pumpAndSettle(); - -// verifyNever(getIt().login( -// username: testUsername, -// password: testPassword, -// )); -// expect( -// find.textContaining( -// t.translations.loginPageServerUrlValidatorMessageText), -// findsOneWidget, -// ); -// }); -// } + expect( + find.byKey(TestKeys.login.loggingInScreen), + findsOneWidget, + ); + }); +} diff --git a/integration_test/src/framework.dart b/integration_test/src/framework.dart index ef95f48..9311c92 100644 --- a/integration_test/src/framework.dart +++ b/integration_test/src/framework.dart @@ -1,7 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/service/connectivity_status_service.dart'; +import 'package:paperless_mobile/features/login/cubit/authentication_cubit.dart'; +import 'package:paperless_mobile/features/login/services/authentication_service.dart'; +import 'package:paperless_mobile/features/notifications/services/local_notification_service.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/main.dart' + show initializeDefaultParameters, AppEntrypoint; +import 'package:path_provider/path_provider.dart'; Future initializeTestingFramework( {String languageCode = 'en'}) async { @@ -26,11 +35,3 @@ class TestingFrameworkVariables { required this.translations, }); } - -Future initAndLaunchTestApp( - WidgetTester tester, - Future Function() initializationCallback, -) async { - await initializationCallback(); - //runApp(const PaperlessMobileEntrypoint(authenticationCubit: ),)); -} diff --git a/integration_test/src/mocks/mock_paperless_api.dart b/integration_test/src/mocks/mock_paperless_api.dart new file mode 100644 index 0000000..81f503e --- /dev/null +++ b/integration_test/src/mocks/mock_paperless_api.dart @@ -0,0 +1,65 @@ +import 'package:dio/src/dio.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; +import 'package:mockito/annotations.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +import 'mock_paperless_api.mocks.dart'; + +class MockPaperlessApiFactory implements PaperlessApiFactory { + final PaperlessAuthenticationApi authenticationApi = + MockPaperlessAuthenticationApi(); + final PaperlessDocumentsApi documentApi = MockPaperlessDocumentsApi(); + final PaperlessLabelsApi labelsApi = MockPaperlessLabelsApi(); + final PaperlessUserApi userApi = MockPaperlessUserApi(); + final PaperlessSavedViewsApi savedViewsApi = MockPaperlessSavedViewsApi(); + final PaperlessServerStatsApi serverStatsApi = MockPaperlessServerStatsApi(); + final PaperlessTasksApi tasksApi = MockPaperlessTasksApi(); + + @override + PaperlessAuthenticationApi createAuthenticationApi(Dio dio) { + return authenticationApi; + } + + @override + PaperlessDocumentsApi createDocumentsApi(Dio dio, {required int apiVersion}) { + return documentApi; + } + + @override + PaperlessLabelsApi createLabelsApi(Dio dio, {required int apiVersion}) { + return labelsApi; + } + + @override + PaperlessSavedViewsApi createSavedViewsApi( + Dio dio, { + required int apiVersion, + }) { + return savedViewsApi; + } + + @override + PaperlessServerStatsApi createServerStatsApi(Dio dio, + {required int apiVersion}) { + return serverStatsApi; + } + + @override + PaperlessTasksApi createTasksApi(Dio dio, {required int apiVersion}) { + return tasksApi; + } + + @override + PaperlessUserApi createUserApi(Dio dio, {required int apiVersion}) { + return userApi; + } +} diff --git a/lib/core/database/hive/hive_initialization.dart b/lib/core/database/hive/hive_initialization.dart new file mode 100644 index 0000000..e5988da --- /dev/null +++ b/lib/core/database/hive/hive_initialization.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:hive/hive.dart'; +import 'package:hive_flutter/adapters.dart'; +import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/tables/global_settings.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; +import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; + +Future initHive(Directory directory, String defaultLocale) async { + Hive.init(directory.path); + registerHiveAdapters(); + await Hive.openBox(HiveBoxes.localUserAccount); + await Hive.openBox(HiveBoxes.localUserAppState); + await Hive.openBox(HiveBoxes.hintStateBox); + await Hive.openBox(HiveBoxes.hosts); + final globalSettingsBox = + await Hive.openBox(HiveBoxes.globalSettings); + + if (!globalSettingsBox.hasValue) { + await globalSettingsBox.setValue( + GlobalSettings(preferredLocaleSubtag: defaultLocale), + ); + } +} diff --git a/lib/core/interceptor/language_header.interceptor.dart b/lib/core/interceptor/language_header.interceptor.dart index 4d81272..862d037 100644 --- a/lib/core/interceptor/language_header.interceptor.dart +++ b/lib/core/interceptor/language_header.interceptor.dart @@ -1,16 +1,16 @@ import 'package:dio/dio.dart'; class LanguageHeaderInterceptor extends Interceptor { - String preferredLocaleSubtag; - LanguageHeaderInterceptor(this.preferredLocaleSubtag); + final String Function() preferredLocaleSubtagBuilder; + LanguageHeaderInterceptor(this.preferredLocaleSubtagBuilder); @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { late String languages; - if (preferredLocaleSubtag == "en") { + if (preferredLocaleSubtagBuilder() == "en") { languages = "en"; } else { - languages = "$preferredLocaleSubtag,en;q=0.7,en-US;q=0.6"; + languages = "${preferredLocaleSubtagBuilder()},en;q=0.7,en-US;q=0.6"; } options.headers.addAll({"Accept-Language": languages}); handler.next(options); diff --git a/lib/core/security/session_manager.dart b/lib/core/security/session_manager.dart index 7e50889..50877d6 100644 --- a/lib/core/security/session_manager.dart +++ b/lib/core/security/session_manager.dart @@ -1,93 +1,14 @@ -import 'dart:io'; - import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; import 'package:flutter/material.dart'; -import 'package:paperless_api/src/interceptor/dio_http_error_interceptor.dart'; -import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart'; -import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart'; -import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; -import 'package:pretty_dio_logger/pretty_dio_logger.dart'; -/// Manages the security context, authentication and base request URL for -/// an underlying [Dio] client which is injected into all services -/// requiring authenticated access to the Paperless REST API. -class SessionManager extends ValueNotifier { - Dio get client => value; - - SessionManager([List interceptors = const []]) - : super(_initDio(interceptors)); - - static Dio _initDio(List interceptors) { - //en- and decoded by utf8 by default - final Dio dio = Dio( - BaseOptions( - contentType: Headers.jsonContentType, - followRedirects: true, - maxRedirects: 10, - ), - ); - dio.options - ..receiveTimeout = const Duration(seconds: 30) - ..sendTimeout = const Duration(seconds: 60) - ..responseType = ResponseType.json; - (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = - () => HttpClient()..badCertificateCallback = (cert, host, port) => true; - dio.interceptors.addAll([ - ...interceptors, - DioUnauthorizedInterceptor(), - DioHttpErrorInterceptor(), - DioOfflineInterceptor(), - RetryOnConnectionChangeInterceptor(dio: dio) - ]); - return dio; - } +abstract interface class SessionManager implements ChangeNotifier { + Dio get client; void updateSettings({ String? baseUrl, String? authToken, ClientCertificate? clientCertificate, - }) { - if (clientCertificate != null) { - final context = SecurityContext() - ..usePrivateKeyBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..useCertificateChainBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ) - ..setTrustedCertificatesBytes( - clientCertificate.bytes, - password: clientCertificate.passphrase, - ); - final adapter = IOHttpClientAdapter() - ..createHttpClient = () => HttpClient(context: context) - ..badCertificateCallback = - (X509Certificate cert, String host, int port) => true; - - client.httpClientAdapter = adapter; - } - - if (baseUrl != null) { - client.options.baseUrl = baseUrl; - } - - if (authToken != null) { - client.options.headers.addAll({ - HttpHeaders.authorizationHeader: 'Token $authToken', - }); - } - - notifyListeners(); - } - - void resetSettings() { - client.httpClientAdapter = IOHttpClientAdapter(); - client.options.baseUrl = ''; - client.options.headers.remove(HttpHeaders.authorizationHeader); - notifyListeners(); - } + }); + void resetSettings(); } diff --git a/lib/core/security/session_manager_impl.dart b/lib/core/security/session_manager_impl.dart new file mode 100644 index 0000000..c9a5491 --- /dev/null +++ b/lib/core/security/session_manager_impl.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter/material.dart'; +import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart'; +import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart'; +import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/features/login/model/client_certificate.dart'; + +/// Manages the security context, authentication and base request URL for +/// an underlying [Dio] client which is injected into all services +/// requiring authenticated access to the Paperless REST API. +class SessionManagerImpl extends ValueNotifier implements SessionManager { + @override + Dio get client => value; + + SessionManagerImpl([List interceptors = const []]) + : super(_initDio(interceptors)); + + static Dio _initDio(List interceptors) { + //en- and decoded by utf8 by default + final Dio dio = Dio( + BaseOptions( + contentType: Headers.jsonContentType, + followRedirects: true, + maxRedirects: 10, + ), + ); + dio.options + ..receiveTimeout = const Duration(seconds: 30) + ..sendTimeout = const Duration(seconds: 60) + ..responseType = ResponseType.json; + (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = + () => HttpClient()..badCertificateCallback = (cert, host, port) => true; + dio.interceptors.addAll([ + ...interceptors, + DioUnauthorizedInterceptor(), + DioHttpErrorInterceptor(), + DioOfflineInterceptor(), + RetryOnConnectionChangeInterceptor(dio: dio) + ]); + return dio; + } + + @override + void updateSettings({ + String? baseUrl, + String? authToken, + ClientCertificate? clientCertificate, + }) { + if (clientCertificate != null) { + final context = SecurityContext() + ..usePrivateKeyBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ) + ..useCertificateChainBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ) + ..setTrustedCertificatesBytes( + clientCertificate.bytes, + password: clientCertificate.passphrase, + ); + final adapter = IOHttpClientAdapter() + ..createHttpClient = () => HttpClient(context: context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + + client.httpClientAdapter = adapter; + } + + if (baseUrl != null) { + client.options.baseUrl = baseUrl; + } + + if (authToken != null) { + client.options.headers.addAll({ + HttpHeaders.authorizationHeader: 'Token $authToken', + }); + } + + notifyListeners(); + } + + @override + void resetSettings() { + client.httpClientAdapter = IOHttpClientAdapter(); + client.options.baseUrl = ''; + client.options.headers.remove(HttpHeaders.authorizationHeader); + notifyListeners(); + } +} diff --git a/lib/core/service/connectivity_status_service.dart b/lib/core/service/connectivity_status_service.dart index 6ce404b..0e95a2f 100644 --- a/lib/core/service/connectivity_status_service.dart +++ b/lib/core/service/connectivity_status_service.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:paperless_mobile/core/global/os_error_codes.dart'; import 'package:paperless_mobile/core/interceptor/server_reachability_error_interceptor.dart'; import 'package:paperless_mobile/core/security/session_manager.dart'; +import 'package:paperless_mobile/core/security/session_manager_impl.dart'; import 'package:paperless_mobile/features/login/model/client_certificate.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:rxdart/subjects.dart'; @@ -79,7 +80,7 @@ class ConnectivityStatusServiceImpl implements ConnectivityStatusService { } try { SessionManager manager = - SessionManager([ServerReachabilityErrorInterceptor()]) + SessionManagerImpl([ServerReachabilityErrorInterceptor()]) ..updateSettings(clientCertificate: clientCertificate) ..client.options.connectTimeout = const Duration(seconds: 5) ..client.options.receiveTimeout = const Duration(seconds: 5); diff --git a/lib/features/document_details/view/widgets/document_notes_widget.dart b/lib/features/document_details/view/widgets/document_notes_widget.dart index 89048e1..3fc50f3 100644 --- a/lib/features/document_details/view/widgets/document_notes_widget.dart +++ b/lib/features/document_details/view/widgets/document_notes_widget.dart @@ -92,6 +92,8 @@ class _DocumentNotesWidgetState extends State { label: Text(S.of(context)!.addNote), onPressed: () async { _formKey.currentState?.save(); + FocusScope.of(context).unfocus(); + if (_formKey.currentState?.validate() ?? false) { setState(() { _isNoteSubmitting = true; diff --git a/lib/features/login/cubit/authentication_cubit.dart b/lib/features/login/cubit/authentication_cubit.dart index 121e779..3263750 100644 --- a/lib/features/login/cubit/authentication_cubit.dart +++ b/lib/features/login/cubit/authentication_cubit.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:paperless_api/paperless_api.dart'; +import 'package:paperless_mobile/core/bloc/transient_error.dart'; import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; @@ -13,6 +14,7 @@ import 'package:paperless_mobile/core/database/tables/local_user_settings.dart'; import 'package:paperless_mobile/core/database/tables/user_credentials.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; +import 'package:paperless_mobile/core/security/session_manager_impl.dart'; import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/logging/utils/redaction_utils.dart'; import 'package:paperless_mobile/core/model/info_message_exception.dart'; @@ -83,7 +85,7 @@ class AuthenticationCubit extends Cubit { AuthenticatingStage.persistingLocalUserData)); }, ); - } catch (e) { + } on PaperlessApiException catch (exception, stackTrace) { emit( AuthenticationErrorState( serverUrl: serverUrl, @@ -207,8 +209,8 @@ class AuthenticationCubit extends Cubit { methodName: 'switchAccount', ); - final sessionManager = SessionManager([ - LanguageHeaderInterceptor(locale), + final SessionManager sessionManager = SessionManagerImpl([ + LanguageHeaderInterceptor(() => locale), ]); await _addUser( localUserId, @@ -462,14 +464,12 @@ class AuthenticationCubit extends Cubit { final authApi = _apiFactory.createAuthenticationApi(sessionManager.client); + await onPerformLogin?.call(); logger.fd( "Fetching bearer token from the server...", className: runtimeType.toString(), methodName: '_addUser', ); - - await onPerformLogin?.call(); - final token = await authApi.login( username: credentials.username!, password: credentials.password!, @@ -486,7 +486,6 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, authToken: token, ); - final userAccountBox = Hive.box(HiveBoxes.localUserAccount); final userStateBox = @@ -586,12 +585,14 @@ class AuthenticationCubit extends Cubit { clientCertificate: clientCert, ), ); + logger.fd( "User credentials successfully saved.", className: runtimeType.toString(), methodName: '_addUser', ); }); + final hostsBox = Hive.box(HiveBoxes.hosts); if (!hostsBox.values.contains(serverUrl)) { await hostsBox.add(serverUrl); diff --git a/lib/features/login/view/add_account_page.dart b/lib/features/login/view/add_account_page.dart index fcc9aab..7bb82c6 100644 --- a/lib/features/login/view/add_account_page.dart +++ b/lib/features/login/view/add_account_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -17,7 +16,6 @@ import 'package:paperless_mobile/features/login/model/client_certificate_form_mo import 'package:paperless_mobile/features/login/model/login_form_credentials.dart'; import 'package:paperless_mobile/features/login/model/reachability_status.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/client_certificate_form_field.dart'; -import 'package:paperless_mobile/features/login/view/widgets/form_fields/login_settings_page.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/server_address_form_field.dart'; import 'package:paperless_mobile/features/login/view/widgets/form_fields/user_credentials_form_field.dart'; import 'package:paperless_mobile/generated/assets.gen.dart'; @@ -44,6 +42,7 @@ class AddAccountPage extends StatefulWidget { final bool showLocalAccounts; final Widget? bottomLeftButton; + const AddAccountPage({ Key? key, required this.onSubmit, diff --git a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart index 171ab27..ac2d476 100644 --- a/lib/features/login/view/widgets/form_fields/server_address_form_field.dart +++ b/lib/features/login/view/widgets/form_fields/server_address_form_field.dart @@ -5,6 +5,7 @@ import 'package:hive_flutter/adapters.dart'; import 'package:paperless_mobile/core/database/hive/hive_config.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/keys.dart'; class ServerAddressFormField extends StatefulWidget { static const String fkServerAddress = "serverAddress"; @@ -59,7 +60,7 @@ class _ServerAddressFormFieldState extends State maxWidth: MediaQuery.sizeOf(context).width - 40, ); }, - key: const ValueKey('login-server-address'), + key: TestKeys.login.serverAddressFormField, optionsBuilder: (textEditingValue) { return Hive.box(HiveBoxes.hosts) .values diff --git a/lib/keys.dart b/lib/keys.dart new file mode 100644 index 0000000..43e3197 --- /dev/null +++ b/lib/keys.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; + +class TestKeys { + TestKeys._(); + + static final login = _LoginTestKeys(); +} + +class _LoginTestKeys { + final serverAddressFormField = const Key('login-server-address'); + final continueButton = const Key('login-continue-button'); + final usernameFormField = const Key('login-username'); + final passwordFormField = const Key('login-password'); + final loginButton = const Key('login-login-button'); + final clientCertificateFormField = const Key('login-client-certificate'); + final clientCertificatePassphraseFormField = + const Key('login-client-certificate-passphrase'); + final loggingInScreen = const Key('login-logging-in-screen'); +} diff --git a/lib/main.dart b/lib/main.dart index c6fb222..55c26fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,8 @@ import 'package:paperless_mobile/constants.dart'; import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart'; import 'package:paperless_mobile/core/bloc/my_bloc_observer.dart'; import 'package:paperless_mobile/core/database/hive/hive_config.dart'; +import 'package:paperless_mobile/core/database/hive/hive_extensions.dart'; +import 'package:paperless_mobile/core/database/hive/hive_initialization.dart'; import 'package:paperless_mobile/core/database/tables/global_settings.dart'; import 'package:paperless_mobile/core/database/tables/local_user_account.dart'; import 'package:paperless_mobile/core/database/tables/local_user_app_state.dart'; @@ -31,7 +33,7 @@ import 'package:paperless_mobile/core/exception/server_message_exception.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory.dart'; import 'package:paperless_mobile/core/factory/paperless_api_factory_impl.dart'; import 'package:paperless_mobile/core/interceptor/language_header.interceptor.dart'; -import 'package:paperless_mobile/core/notifier/go_router_refresh_stream.dart'; +import 'package:paperless_mobile/core/security/session_manager_impl.dart'; import 'package:paperless_mobile/features/logging/data/formatted_printer.dart'; import 'package:paperless_mobile/features/logging/data/logger.dart'; import 'package:paperless_mobile/features/logging/data/mirrored_file_output.dart'; @@ -105,65 +107,36 @@ Future performMigrations() async { } } -Future _initHive() async { - await Hive.initFlutter(); - await performMigrations(); - registerHiveAdapters(); - await Hive.openBox(HiveBoxes.localUserAccount); - await Hive.openBox(HiveBoxes.localUserAppState); - await Hive.openBox(HiveBoxes.hintStateBox); - await Hive.openBox(HiveBoxes.hosts); - final globalSettingsBox = - await Hive.openBox(HiveBoxes.globalSettings); +Future initializeDefaultParameters() async { + Bloc.observer = MyBlocObserver(); + await FileService.instance.initialize(); + logger = l.Logger( + output: MirroredFileOutput(), + printer: FormattedPrinter(), + level: l.Level.trace, + filter: l.ProductionFilter(), + ); - if (!globalSettingsBox.hasValue) { - await globalSettingsBox.setValue( - GlobalSettings(preferredLocaleSubtag: defaultPreferredLocale.toString()), - ); + packageInfo = await PackageInfo.fromPlatform(); + + if (Platform.isAndroid) { + androidInfo = await DeviceInfoPlugin().androidInfo; } + if (Platform.isIOS) { + iosInfo = await DeviceInfoPlugin().iosInfo; + } + + await findSystemLocale(); } void main() async { runZonedGuarded(() async { - Bloc.observer = MyBlocObserver(); - WidgetsFlutterBinding.ensureInitialized(); - await FileService.instance.initialize(); - - logger = l.Logger( - output: MirroredFileOutput(), - printer: FormattedPrinter(), - level: l.Level.trace, - filter: l.ProductionFilter(), - ); - Paint.enableDithering = true; - - // if (kDebugMode) { - // // URL: http://localhost:3131 - // // Login: admin:test - // await LocalMockApiServer( - // // RandomDelayGenerator( - // // const Duration(milliseconds: 100), - // // const Duration(milliseconds: 800), - // // ), - // ) - // .start(); - // } - - packageInfo = await PackageInfo.fromPlatform(); - - if (Platform.isAndroid) { - androidInfo = await DeviceInfoPlugin().androidInfo; - } - if (Platform.isIOS) { - iosInfo = await DeviceInfoPlugin().iosInfo; - } - await _initHive(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - final globalSettingsBox = - Hive.box(HiveBoxes.globalSettings); - final globalSettings = globalSettingsBox.getValue()!; - - await findSystemLocale(); + final hiveDirectory = await getApplicationDocumentsDirectory(); + final defaultLocale = defaultPreferredLocale.languageCode; + await initializeDefaultParameters(); + await initHive(hiveDirectory, defaultLocale); + await performMigrations(); final connectivityStatusService = ConnectivityStatusServiceImpl( Connectivity(), @@ -179,10 +152,10 @@ void main() async { FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); final languageHeaderInterceptor = LanguageHeaderInterceptor( - globalSettings.preferredLocaleSubtag, + () => Hive.globalSettingsBox.getValue()!.preferredLocaleSubtag, ); // Manages security context, required for self signed client certificates - final sessionManager = SessionManager([ + final SessionManager sessionManager = SessionManagerImpl([ PrettyDioLogger( compact: true, responseBody: false, @@ -195,21 +168,9 @@ void main() async { languageHeaderInterceptor, ]); - // Initialize Blocs/Cubits - final connectivityCubit = ConnectivityCubit(connectivityStatusService); - - // Load application settings and stored authentication data - await connectivityCubit.initialize(); - final localNotificationService = LocalNotificationService(); await localNotificationService.initialize(); - //Update language header in interceptor on language change. - globalSettingsBox.listenable().addListener(() { - languageHeaderInterceptor.preferredLocaleSubtag = - globalSettings.preferredLocaleSubtag; - }); - final apiFactory = PaperlessApiFactoryImpl(sessionManager); final authenticationCubit = AuthenticationCubit( localAuthService, @@ -219,33 +180,19 @@ void main() async { localNotificationService, ); runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: sessionManager), - Provider.value(value: localAuthService), - Provider.value( - value: connectivityStatusService), - Provider.value( - value: localNotificationService), - Provider.value(value: DocumentChangedNotifier()), - ], - child: MultiProvider( - providers: [ - Provider.value(value: connectivityCubit), - Provider.value(value: authenticationCubit), - ], - child: GoRouterShell( - apiFactory: apiFactory, - ), - ), + AppEntrypoint( + sessionManager: sessionManager, + apiFactory: apiFactory, + authenticationCubit: authenticationCubit, + connectivityStatusService: connectivityStatusService, + localNotificationService: localNotificationService, + localAuthService: localAuthService, ), ); }, (error, stackTrace) { if (error is StateError && error.message.contains("Cannot emit new states")) { - { - return; - } + return; } // Catches all unexpected/uncaught errors and prints them to the console. final message = switch (error) { @@ -262,9 +209,52 @@ void main() async { }); } +class AppEntrypoint extends StatelessWidget { + final PaperlessApiFactory apiFactory; + final AuthenticationCubit authenticationCubit; + final ConnectivityStatusService connectivityStatusService; + final LocalNotificationService localNotificationService; + final LocalAuthenticationService localAuthService; + final SessionManager sessionManager; + + const AppEntrypoint({ + super.key, + required this.apiFactory, + required this.authenticationCubit, + required this.connectivityStatusService, + required this.localNotificationService, + required this.localAuthService, + required this.sessionManager, + }); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider.value(value: DocumentChangedNotifier()), + Provider.value(value: authenticationCubit), + Provider.value( + value: ConnectivityCubit(connectivityStatusService)..initialize(), + ), + ChangeNotifierProvider.value(value: sessionManager), + Provider.value(value: connectivityStatusService), + Provider.value(value: localNotificationService), + Provider.value(value: localAuthService), + ], + child: GoRouterShell( + apiFactory: apiFactory, + ), + ); + } +} + class GoRouterShell extends StatefulWidget { final PaperlessApiFactory apiFactory; - const GoRouterShell({super.key, required this.apiFactory}); + + const GoRouterShell({ + super.key, + required this.apiFactory, + }); @override State createState() => _GoRouterShellState(); @@ -397,7 +387,7 @@ class _GoRouterShellState extends State { dynamicScheme: darkDynamic, preferredColorScheme: settings.preferredColorSchemeOption, ), - themeMode: settings.preferredThemeMode, + themeMode: settings.preferredThemeMode, supportedLocales: const [ Locale('en'), Locale('de'), diff --git a/lib/routing/routes/login_route.dart b/lib/routing/routes/login_route.dart index 835ba58..96c5547 100644 --- a/lib/routing/routes/login_route.dart +++ b/lib/routing/routes/login_route.dart @@ -12,6 +12,7 @@ import 'package:paperless_mobile/features/login/view/login_to_existing_account_p import 'package:paperless_mobile/features/login/view/verify_identity_page.dart'; import 'package:paperless_mobile/features/login/view/widgets/login_transition_page.dart'; import 'package:paperless_mobile/generated/l10n/app_localizations.dart'; +import 'package:paperless_mobile/keys.dart'; import 'package:paperless_mobile/routing/navigation_keys.dart'; import 'package:paperless_mobile/routing/routes.dart'; part 'login_route.g.dart'; @@ -108,6 +109,7 @@ class AuthenticatingRoute extends GoRouteData { }; return NoTransitionPage( child: LoginTransitionPage( + key: TestKeys.login.loggingInScreen, text: text, ), ); diff --git a/lib/theme.dart b/lib/theme.dart index 84771fc..7331415 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -82,14 +82,14 @@ SystemUiOverlayStyle buildOverlayStyle( Brightness.light => SystemUiOverlayStyle.dark.copyWith( systemNavigationBarColor: color, systemNavigationBarDividerColor: color, - // statusBarColor: theme.colorScheme.background, + statusBarColor: theme.colorScheme.background, // statusBarColor: theme.colorScheme.background, // systemNavigationBarDividerColor: theme.colorScheme.surface, ), Brightness.dark => SystemUiOverlayStyle.light.copyWith( systemNavigationBarColor: color, systemNavigationBarDividerColor: color, - // statusBarColor: theme.colorScheme.background, + statusBarColor: theme.colorScheme.background, // statusBarColor: theme.colorScheme.background, // systemNavigationBarDividerColor: theme.colorScheme.surface, ), diff --git a/packages/paperless_api/lib/src/models/document_model.dart b/packages/paperless_api/lib/src/models/document_model.dart index 9cbc03a..38df520 100644 --- a/packages/paperless_api/lib/src/models/document_model.dart +++ b/packages/paperless_api/lib/src/models/document_model.dart @@ -73,7 +73,7 @@ class DocumentModel extends Equatable { this.userCanChange, this.permissions, this.customFields = const [], - this.notes = const [] = const [], + this.notes = const [], }); factory DocumentModel.fromJson(Map json) => diff --git a/packages/paperless_document_scanner/example/pubspec.lock b/packages/paperless_document_scanner/example/pubspec.lock index 433f843..46dcade 100644 --- a/packages/paperless_document_scanner/example/pubspec.lock +++ b/packages/paperless_document_scanner/example/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" colorfilter_generator: dependency: transitive description: @@ -244,10 +244,10 @@ packages: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" paperless_document_scanner: dependency: "direct main" description: @@ -376,18 +376,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -416,10 +416,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" typed_data: dependency: transitive description: @@ -440,10 +440,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" win32: dependency: transitive description: @@ -469,5 +469,5 @@ packages: source: hosted version: "6.3.0" sdks: - dart: ">=3.1.0 <4.0.0" + dart: ">=3.2.0-194.0.dev <4.0.0" flutter: ">=3.13.0"