From a75e094fe4ffe3f1060a6582f039c4679b766c96 Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:51:16 +0100 Subject: [PATCH 1/5] Refactor PiMailer to accept multiple recipients and simplify email subject construction --- lib/mains/main_customizer.dart | 4 +- lib/mains/main_netknights.dart | 4 +- .../enums/image_file_type_extension.dart | 42 ++++ lib/model/widget_image.dart | 23 ++- lib/model/widget_image.g.dart | 2 + .../application_customization.dart | 187 +++++++++++++++--- lib/utils/logger.dart | 166 ++++++++++++---- lib/utils/pi_mailer.dart | 61 +++--- .../application_customizer_provider.g.dart | 2 +- lib/utils/view_utils.dart | 24 +++ .../link_input_view.dart | 4 +- lib/views/feedback_view/feedback_view.dart | 103 +++------- .../widgets/feedback_send_row.dart | 147 ++++++++++++++ lib/views/splash_screen/splash_screen.dart | 4 +- .../application_customization_test.dart | 32 +-- 15 files changed, 613 insertions(+), 192 deletions(-) create mode 100644 lib/views/feedback_view/widgets/feedback_send_row.dart diff --git a/lib/mains/main_customizer.dart b/lib/mains/main_customizer.dart index 36d4b41df..44c329f5c 100644 --- a/lib/mains/main_customizer.dart +++ b/lib/mains/main_customizer.dart @@ -84,12 +84,12 @@ class CustomizationAuthenticator extends ConsumerWidget { FeedbackView.routeName: (context) => const FeedbackView(), ImportTokensView.routeName: (context) => const ImportTokensView(), LicenseView.routeName: (context) => LicenseView( - appImage: applicationCustomizer.appImage.getWidget, + appImage: applicationCustomizer.licensesViewImage.getWidget, appName: applicationCustomizer.appName, websiteLink: applicationCustomizer.websiteLink, ), MainView.routeName: (context) => MainView( - appIcon: applicationCustomizer.appIcon.getWidget, + appIcon: applicationCustomizer.appbarIcon.getWidget, appName: applicationCustomizer.appName, disablePatchNotes: applicationCustomizer.disabledFeatures.contains(AppFeature.patchNotes), ), diff --git a/lib/mains/main_netknights.dart b/lib/mains/main_netknights.dart index 2b189012e..76d01c979 100644 --- a/lib/mains/main_netknights.dart +++ b/lib/mains/main_netknights.dart @@ -102,12 +102,12 @@ class PrivacyIDEAAuthenticator extends ConsumerWidget { FeedbackView.routeName: (context) => const FeedbackView(), ImportTokensView.routeName: (context) => const ImportTokensView(), LicenseView.routeName: (context) => LicenseView( - appImage: _customization.appImage.getWidget, + appImage: _customization.licensesViewImage.getWidget, appName: _customization.appName, websiteLink: _customization.websiteLink, ), MainView.routeName: (context) => MainView( - appIcon: _customization.appIcon.getWidget, + appIcon: _customization.appbarIcon.getWidget, appName: _customization.appName, disablePatchNotes: _customization.disabledFeatures.contains(AppFeature.patchNotes), ), diff --git a/lib/model/extensions/enums/image_file_type_extension.dart b/lib/model/extensions/enums/image_file_type_extension.dart index 3d77b1464..27e3a46fc 100644 --- a/lib/model/extensions/enums/image_file_type_extension.dart +++ b/lib/model/extensions/enums/image_file_type_extension.dart @@ -21,10 +21,23 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:image_picker/image_picker.dart'; import '../../enums/image_file_type.dart'; extension ImageFileTypeX on ImageFileType { + static ImageFileType fromExtensionString(String ex) => switch (ex) { + 'svg' => ImageFileType.svg, + 'svgz' => ImageFileType.svgz, + 'png' => ImageFileType.png, + 'jpg' => ImageFileType.jpg, + 'jpeg' => ImageFileType.jpeg, + 'gif' => ImageFileType.gif, + 'bmp' => ImageFileType.bmp, + 'webp' => ImageFileType.webp, + _ => throw Exception('Unknown extension: $ex'), + }; + Widget buildImageWidget(Uint8List imageData) => switch (this) { ImageFileType.svg => SvgPicture.memory( imageData, @@ -65,4 +78,33 @@ extension ImageFileTypeX on ImageFileType { colorBlendMode: BlendMode.srcOver, ), }; + + String get extension => toString().split('.').last; + + String get typeName => switch (this) { + ImageFileType.svg => 'Scalable Vector Graphic', + ImageFileType.svgz => 'Scalable Vector Graphic (compressed)', + ImageFileType.png => 'PNG', + ImageFileType.jpg => 'JPEG', + ImageFileType.jpeg => 'JPEG', + ImageFileType.gif => 'GIF', + ImageFileType.bmp => 'Bitmap', + ImageFileType.webp => 'WebP', + }; + + String get mimeType => switch (this) { + ImageFileType.svg => 'image/svg+xml', + ImageFileType.svgz => 'image/svg+xml', + ImageFileType.png => 'image/png', + ImageFileType.jpg => 'image/jpeg', + ImageFileType.jpeg => 'image/jpeg', + ImageFileType.gif => 'image/gif', + ImageFileType.bmp => 'image/bmp', + ImageFileType.webp => 'image/webp', + }; + + /// Builds an [XFile] from the given [imageData] and [fileName]. + /// The [fileName] is used as the name of the file. + /// The file extension is determined by the [ImageFileType]. + XFile buildXFile(Uint8List imageData, String fileName) => XFile.fromData(imageData, name: "$fileName.$extension"); } diff --git a/lib/model/widget_image.dart b/lib/model/widget_image.dart index 4bcd4d801..2bbc2bc74 100644 --- a/lib/model/widget_image.dart +++ b/lib/model/widget_image.dart @@ -21,10 +21,11 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:privacyidea_authenticator/model/extensions/enums/image_file_type_extension.dart'; +import '../../../../../../../model/extensions/enums/image_file_type_extension.dart'; import '../utils/logger.dart'; import 'enums/image_file_type.dart'; @@ -45,12 +46,26 @@ class Uint8ListConverter implements JsonConverter { class WidgetImage { final ImageFileType fileType; final Uint8List imageData; + final String fileName; + + String get fullFileName => '$fileName.${fileType.extension}'; WidgetImage({ required this.fileType, required this.imageData, + required this.fileName, }); + @override + String toString() { + return 'WidgetImage{fileType: $fileType, imageData: $imageData}'; + } + + @override + bool operator ==(Object other) => other is WidgetImage && other.fileType == fileType && other.imageData == imageData; + @override + int get hashCode => Object.hash(runtimeType, fileType, imageData); + Widget? _widget; Widget get getWidget { if (_widget != null) return _widget!; @@ -62,11 +77,15 @@ class WidgetImage { try { return fileType.buildImageWidget(imageData); } catch (e) { - Logger.error('File type $fileType is not supported or does not match the image data.', name: 'WidgetImage#_buildImageWidget'); + Logger.error('Image is not an ${fileType.typeName}, or the image data is corrupted.', error: e); rethrow; } } factory WidgetImage.fromJson(Map json) => _$WidgetImageFromJson(json); Map toJson() => _$WidgetImageToJson(this); + + XFile? toXFile() { + return fileType.buildXFile(imageData, fileName); + } } diff --git a/lib/model/widget_image.g.dart b/lib/model/widget_image.g.dart index f066d20bb..d1d939d56 100644 --- a/lib/model/widget_image.g.dart +++ b/lib/model/widget_image.g.dart @@ -10,12 +10,14 @@ WidgetImage _$WidgetImageFromJson(Map json) => WidgetImage( fileType: $enumDecode(_$ImageFileTypeEnumMap, json['fileType']), imageData: const Uint8ListConverter().fromJson(json['imageData'] as String), + fileName: json['fileName'] as String, ); Map _$WidgetImageToJson(WidgetImage instance) => { 'fileType': _$ImageFileTypeEnumMap[instance.fileType]!, 'imageData': const Uint8ListConverter().toJson(instance.imageData), + 'fileName': instance.fileName, }; const _$ImageFileTypeEnumMap = { diff --git a/lib/utils/customization/application_customization.dart b/lib/utils/customization/application_customization.dart index 47a6acc50..f1f8a1218 100644 --- a/lib/utils/customization/application_customization.dart +++ b/lib/utils/customization/application_customization.dart @@ -1,14 +1,43 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../utils/customization/theme_customization.dart'; +import 'package:privacyidea_authenticator/utils/app_info_utils.dart'; +import '../../../utils/customization/theme_customization.dart'; import '../../model/enums/app_feature.dart'; import '../../model/enums/image_file_type.dart'; import '../../model/widget_image.dart'; class ApplicationCustomization { + static final defaultCustomization = ApplicationCustomization(); + static const _defaultAppName = 'privacyIDEA Authenticator'; + static const _defaultWebsiteLink = 'https://netknights.it/'; + static const _defaultCrashRecipient = 'app-crash@netknights.it'; + static const _defaultCrashSubjectPrefix = '(\$version) privacyIDEA Authenticator >>>'; + static const _defaultFeedbackRecipient = 'app-crash@netknights.it'; + static const _defaultFeedbackSubjectPrefix = '(\$version) privacyIDEA Authenticator >>> Feedback'; + static const prefixVersionVariable = '\$version'; + static const String _defaultFontName = 'defaultFont'; final String fontFamilyName; final Uint8List? customFontBytes; @@ -43,34 +72,59 @@ class ApplicationCustomization { final String appName; final String websiteLink; - final WidgetImage appIcon; - - final WidgetImage appImage; + final String crashRecipient; + final String rawCrashSubjectPrefix; + String get crashSubjectPrefix => rawCrashSubjectPrefix.replaceAll(prefixVersionVariable, AppInfoUtils.currentVersionAndBuildNumber); + final String feedbackRecipient; + final String rawFeedbackSubjectPrefix; + String get feedbackSubjectPrefix => rawFeedbackSubjectPrefix.replaceAll(prefixVersionVariable, AppInfoUtils.currentVersionAndBuildNumber); + final WidgetImage appbarIcon; + static const String appbarIconFileName = 'appbar_icon'; + final WidgetImage splashScreenImage; + static const String splashScreenImageFileName = 'splash_screen_image'; + final WidgetImage backgroundImage; + static const String backgroundImageFileName = 'background_image'; + final WidgetImage licensesViewImage; + static const String licensesViewImageFileName = 'licenses_view_image'; final ThemeCustomization lightTheme; final ThemeCustomization darkTheme; final Set disabledFeatures; - static final defaultCustomization = ApplicationCustomization(); - ApplicationCustomization({ - this.appName = 'privacyIDEA Authenticator', - this.websiteLink = 'https://netknights.it/', + this.appName = _defaultAppName, + this.websiteLink = _defaultWebsiteLink, + this.crashRecipient = _defaultCrashRecipient, + this.rawCrashSubjectPrefix = _defaultCrashSubjectPrefix, + this.feedbackRecipient = _defaultFeedbackRecipient, + this.rawFeedbackSubjectPrefix = _defaultFeedbackSubjectPrefix, this.fontFamilyName = _defaultFontName, this.customFontBytes, - WidgetImage? appIcon, - WidgetImage? appImage, + WidgetImage? appbarIcon, + WidgetImage? splashScreenImage, + WidgetImage? backgroundImage, + WidgetImage? licensesViewImage, this.lightTheme = ThemeCustomization.defaultLightTheme, this.darkTheme = ThemeCustomization.defaultDarkTheme, this.disabledFeatures = const {}, - }) : appIcon = appIcon ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultIconUint8List), - appImage = appImage ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultImageUint8List); + }) : appbarIcon = appbarIcon ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultIconUint8List, fileName: appbarIconFileName), + splashScreenImage = + splashScreenImage ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultImageUint8List, fileName: splashScreenImageFileName), + backgroundImage = backgroundImage ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultImageUint8List, fileName: backgroundImageFileName), + licensesViewImage = + licensesViewImage ?? WidgetImage(fileType: ImageFileType.png, imageData: defaultImageUint8List, fileName: licensesViewImageFileName); ApplicationCustomization copyWith({ String? appName, String? websiteLink, - WidgetImage? appIcon, - WidgetImage? appImage, + String? crashRecipient, + String? crashSubjectPrefix, + String? feedbackRecipient, + String? feedbackSubjectPrefix, + WidgetImage? appbarIcon, + WidgetImage? splashScreenImage, + WidgetImage? backgroundImage, + WidgetImage? licensesViewImage, ThemeCustomization? lightTheme, ThemeCustomization? darkTheme, Set? disabledFeatures, @@ -78,21 +132,74 @@ class ApplicationCustomization { ApplicationCustomization( appName: appName ?? this.appName, websiteLink: websiteLink ?? this.websiteLink, - appIcon: appIcon ?? this.appIcon, - appImage: appImage ?? this.appImage, + crashRecipient: crashRecipient ?? this.crashRecipient, + rawCrashSubjectPrefix: crashSubjectPrefix ?? rawCrashSubjectPrefix, + feedbackRecipient: feedbackRecipient ?? this.feedbackRecipient, + rawFeedbackSubjectPrefix: feedbackSubjectPrefix ?? rawFeedbackSubjectPrefix, + fontFamilyName: fontFamilyName, + customFontBytes: customFontBytes, + appbarIcon: appbarIcon ?? this.appbarIcon, + splashScreenImage: splashScreenImage ?? this.splashScreenImage, + backgroundImage: backgroundImage ?? this.backgroundImage, + licensesViewImage: licensesViewImage ?? this.licensesViewImage, lightTheme: lightTheme ?? this.lightTheme, darkTheme: darkTheme ?? this.darkTheme, disabledFeatures: disabledFeatures ?? this.disabledFeatures, ); + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApplicationCustomization && + appName == other.appName && + websiteLink == other.websiteLink && + crashRecipient == other.crashRecipient && + rawCrashSubjectPrefix == other.rawCrashSubjectPrefix && + feedbackRecipient == other.feedbackRecipient && + rawFeedbackSubjectPrefix == other.rawFeedbackSubjectPrefix && + fontFamilyName == other.fontFamilyName && + customFontBytes == other.customFontBytes && + appbarIcon == other.appbarIcon && + splashScreenImage == other.splashScreenImage && + backgroundImage == other.backgroundImage && + licensesViewImage == other.licensesViewImage && + lightTheme == other.lightTheme && + darkTheme == other.darkTheme && + disabledFeatures == other.disabledFeatures; + + @override + int get hashCode => Object.hashAll([ + appName, + websiteLink, + crashRecipient, + rawCrashSubjectPrefix, + feedbackRecipient, + rawFeedbackSubjectPrefix, + fontFamilyName, + customFontBytes, + appbarIcon, + splashScreenImage, + backgroundImage, + licensesViewImage, + lightTheme, + darkTheme, + disabledFeatures, + ]); + Future updateFont(Uint8List fontBytes, String fontName) async { final newState = ApplicationCustomization( appName: appName, websiteLink: websiteLink, - customFontBytes: fontBytes, + crashRecipient: crashRecipient, + rawCrashSubjectPrefix: rawCrashSubjectPrefix, + feedbackRecipient: feedbackRecipient, + rawFeedbackSubjectPrefix: rawFeedbackSubjectPrefix, fontFamilyName: fontName, - appIcon: appIcon, - appImage: appImage, + customFontBytes: fontBytes, + appbarIcon: appbarIcon, + splashScreenImage: splashScreenImage, + backgroundImage: backgroundImage, + licensesViewImage: licensesViewImage, lightTheme: lightTheme, darkTheme: darkTheme, disabledFeatures: disabledFeatures, @@ -113,12 +220,34 @@ class ApplicationCustomization { ThemeData generateDarkTheme() => darkTheme.generateTheme(fontFamily: customFontBytes != null ? fontFamilyName : null); factory ApplicationCustomization.fromJson(Map json) => ApplicationCustomization( - appName: json['appName'] as String? ?? 'privacyIDEA Authenticator', - websiteLink: json['websiteLink'] as String? ?? 'https://netknights.it/', + appName: json['appName'] as String? ?? _defaultAppName, + websiteLink: json['websiteLink'] as String? ?? _defaultWebsiteLink, + crashRecipient: json['crashRecipient'] as String? ?? _defaultCrashRecipient, + rawCrashSubjectPrefix: json['crashSubjectPrefix'] as String? ?? _defaultCrashSubjectPrefix, + feedbackRecipient: json['feedbackRecipient'] as String? ?? _defaultFeedbackRecipient, + rawFeedbackSubjectPrefix: json['feedbackSubjectPrefix'] as String? ?? _defaultFeedbackSubjectPrefix, customFontBytes: json['customFontBytes'] != null ? base64Decode(json['customFontBytes'] as String) : null, fontFamilyName: json['fontFamilyName'] as String? ?? _defaultFontName, - appIcon: json['appIcon'] == null ? null : WidgetImage.fromJson(json['appIcon'] as Map), - appImage: json['appImage'] == null ? null : WidgetImage.fromJson(json['appImage'] as Map), + appbarIcon: json['appbarIcon'] != null + ? WidgetImage.fromJson(json['appbarIcon'] as Map) + : json['appIcon'] != null + ? WidgetImage.fromJson(json['appIcon'] as Map) + : null, + splashScreenImage: json['splashScreenImage'] != null + ? WidgetImage.fromJson(json['splashScreenImage'] as Map) + : json['appImage'] != null + ? WidgetImage.fromJson(json['appImage'] as Map) + : null, + backgroundImage: json['backgroundImage'] != null + ? WidgetImage.fromJson(json['backgroundImage'] as Map) + : json['appImage'] != null + ? WidgetImage.fromJson(json['appImage'] as Map) + : null, + licensesViewImage: json['licensesViewImage'] != null + ? WidgetImage.fromJson(json['licensesViewImage'] as Map) + : json['appImage'] != null + ? WidgetImage.fromJson(json['appImage'] as Map) + : null, lightTheme: json['lightTheme'] != null ? ThemeCustomization.fromJson(json['lightTheme'] as Map) : ThemeCustomization.defaultLightTheme, darkTheme: json['darkTheme'] != null ? ThemeCustomization.fromJson(json['darkTheme'] as Map) : ThemeCustomization.defaultDarkTheme, disabledFeatures: @@ -128,10 +257,16 @@ class ApplicationCustomization { Map toJson() => { 'appName': appName, 'websiteLink': websiteLink, - 'customFontBytes': customFontBytes != null ? base64Encode(customFontBytes!) : null, + 'crashRecipient': crashRecipient, + 'crashSubjectPrefix': rawCrashSubjectPrefix, + 'feedbackRecipient': feedbackRecipient, + 'feedbackSubjectPrefix': rawFeedbackSubjectPrefix, 'fontFamilyName': fontFamilyName, - 'appIcon': appIcon.toJson(), - 'appImage': appImage.toJson(), + 'customFontBytes': customFontBytes != null ? base64Encode(customFontBytes!) : null, + 'appbarIcon': appbarIcon.toJson(), + 'splashScreenImage': splashScreenImage.toJson(), + 'backgroundImage': backgroundImage.toJson(), + 'licensesViewImage': licensesViewImage.toJson(), 'lightTheme': lightTheme.toJson(), 'darkTheme': darkTheme.toJson(), 'disabledFeatures': disabledFeatures.map((e) => e.name).toList(), diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index c017ca7ef..30af83678 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -1,9 +1,29 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ // ignore_for_file: constant_identifier_names import 'dart:async'; +import 'dart:developer'; import 'dart:io'; import 'dart:isolate'; -import 'dart:math'; +import 'dart:math' show min; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,11 +33,13 @@ import 'package:mutex/mutex.dart'; import 'package:path_provider/path_provider.dart'; import '../l10n/app_localizations.dart'; +import '../mains/main_netknights.dart'; import '../utils/app_info_utils.dart'; import '../utils/pi_mailer.dart'; import '../views/settings_view/settings_view_widgets/send_error_dialog.dart'; import 'globals.dart'; import 'riverpod_providers.dart'; +import 'view_utils.dart'; final provider = Provider((ref) => 0); @@ -27,12 +49,18 @@ class Logger { static Logger? _instance; static BuildContext? get _context => navigatorKey.currentContext; static String get _mailBody => _context != null ? AppLocalizations.of(_context!)!.errorMailBody : 'Error Log File Attached'; + static Set get _mailRecipients => { + ...globalRef?.read(settingsProvider).crashReportRecipients ?? {}, + ...PrivacyIDEAAuthenticator.currentCustomization != null ? {PrivacyIDEAAuthenticator.currentCustomization!.crashRecipient} : {} + }; static printer.Logger print = printer.Logger( printer: printer.PrettyPrinter( methodCount: 0, + levelColors: { + printer.Level.debug: const printer.AnsiColor.fg(040), + }, colors: true, printEmojis: true, - printTime: false, ), ); @@ -60,7 +88,7 @@ class Logger { return file.readAsString(); } - /*----------- INSTANCE MEMBER & GETTER -----------*/ + /*----------- INSTANCE MEMBER & GETTER/SETTER -----------*/ Function? _appRunner; Widget? _app; String _lastError = 'No error Message'; @@ -71,7 +99,7 @@ class Logger { String get _filename => 'logfile.txt'; String? get _fullPath => _logPath != null ? '$_logPath/$_filename' : null; - bool get _verbose { + bool get _verboseLogging { if (globalRef == null) return false; return globalRef!.read(settingsProvider).verboseLogging; } @@ -110,32 +138,78 @@ class Logger { /*----------- LOGGING METHODS -----------*/ - void logInfo(String message, {dynamic stackTrace, String? name, bool verbose = false}) { - String infoString = _convertLogToSingleString(message, stackTrace: stackTrace, name: name, logLevel: LogLevel.INFO); + static void info(String message, {dynamic error, StackTrace? stackTrace, String? name, bool verbose = false}) => + instance._logInfo(message, stackTrace: stackTrace, name: name, verbose: verbose); + + void _logInfo(String message, {dynamic stackTrace, String? name, bool verbose = false}) { + if (_verboseLogging == false && kDebugMode == false && verbose == false) return; + String infoString = _convertLogToSingleString( + message, + stackTrace: stackTrace, + name: name ?? _getCallerMethodName(depth: 2), + logLevel: LogLevel.INFO, + ); infoString = _textFilter(infoString); - if (_verbose || verbose) { + if (_verboseLogging || verbose) { _logToFile(infoString); } _print(infoString); } - static void info(String message, {dynamic error, dynamic stackTrace, String? name, bool verbose = false}) => - instance.logInfo(message, stackTrace: stackTrace, name: name, verbose: verbose); - - void logWarning(String message, {dynamic error, dynamic stackTrace, String? name, bool verbose = false}) { - String warningString = _convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.WARNING); + static void warning(String message, {dynamic error, StackTrace? stackTrace, String? name, bool verbose = false}) => + instance._logWarning(message, error: error, stackTrace: stackTrace, name: name, verbose: verbose); + + void _logWarning(String message, {dynamic error, StackTrace? stackTrace, String? name, bool verbose = false}) { + if (_verboseLogging == false && kDebugMode == false && verbose == false) return; + String warningString = _convertLogToSingleString( + message, + error: error, + stackTrace: stackTrace, + name: name ?? _getCallerMethodName(depth: 2), + logLevel: LogLevel.WARNING, + ); warningString = _textFilter(warningString); - if (instance._verbose || verbose) { + if (_verboseLogging || verbose) { instance._logToFile(warningString); } _printWarning(warningString); } - static void warning(String message, {dynamic error, dynamic stackTrace, String? name, bool verbose = false}) => - instance.logWarning(message, error: error, stackTrace: stackTrace, name: name, verbose: verbose); + /// Does nothing if in production/release mode + static void debug(String message, {dynamic error, StackTrace? stackTrace, String? name, bool verbose = false}) { + if (!kDebugMode) return; + instance._logDebug(message, error: error, stackTrace: stackTrace, name: name, verbose: verbose); + } + + void _logDebug(String message, {dynamic error, StackTrace? stackTrace, String? name, bool verbose = false}) { + if (_verboseLogging == false && kDebugMode == false && verbose == false) return; + if (stackTrace != null) { + log('Stacktrace is not supported in debug mode'); + } + String debugString = instance._convertLogToSingleString( + message, + stackTrace: stackTrace ?? ((_verboseLogging || verbose) ? StackTrace.current : null), + name: name ?? _getCallerMethodName(depth: 2), + logLevel: LogLevel.DEBUG, + ); + debugString = _textFilter(debugString); + if (_verboseLogging || verbose) { + instance._logToFile(debugString); + } + _printDebug(debugString); + } - void logError(String? message, {dynamic error, dynamic stackTrace, String? name}) { - String errorString = _convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.ERROR); + static void error(String? message, {dynamic error, dynamic stackTrace, String? name}) => + instance._logError(message, error: error, stackTrace: stackTrace, name: name); + + void _logError(String? message, {dynamic error, dynamic stackTrace, String? name}) { + String errorString = _convertLogToSingleString( + message, + error: error, + stackTrace: stackTrace, + name: name ?? _getCallerMethodName(depth: 2), + logLevel: LogLevel.ERROR, + ); errorString = _textFilter(errorString); if (message != null) { _lastError = message.substring(0, min(message.length, 100)); @@ -143,7 +217,7 @@ class Logger { _lastError = error.toString().substring(0, min(error.toString().length, 100)); } _logToFile(errorString); - _showSnackbar(); + _showErrorSnackbar(); StackTrace? stackTraceObject; if (stackTrace is StackTrace) { stackTraceObject = stackTrace; @@ -153,9 +227,6 @@ class Logger { _printError(message, error: error, stackTrace: stackTraceObject, name: name); } - static void error(String? message, {dynamic error, dynamic stackTrace, String? name}) => - instance.logError(message, error: error, stackTrace: stackTrace, name: name); - Future _logToFile(String fileMessage) async { if (_enableLoggingToFile == false) return; await _mutexWriteFile.acquire(); @@ -167,8 +238,6 @@ class Logger { await file.writeAsString('\n$fileMessage', mode: FileMode.append); } catch (e) { _printError(e.toString()); - } finally { - _print('Message logged into file'); } _mutexWriteFile.release(); } @@ -189,8 +258,13 @@ class Logger { --------------------------------------------------------- Device Parameters $deviceInfo"""; - - return PiMailer.sendMail(subject: _lastError, body: completeMailBody, attachments: [_fullPath!]); + return PiMailer.sendMail( + mailRecipients: _mailRecipients, + subjectPrefix: PrivacyIDEAAuthenticator.currentCustomization?.crashSubjectPrefix, + subject: _lastError, + body: completeMailBody, + attachments: [_fullPath!], + ); } static void clearErrorLog() { @@ -201,15 +275,7 @@ Device Parameters $deviceInfo"""; final directory = await getApplicationDocumentsDirectory(); final file = File('${directory.path}/$_filename'); await file.writeAsString('', mode: FileMode.write); - globalSnackbarKey.currentState?.showSnackBar( - SnackBar( - content: Text( - _context != null ? AppLocalizations.of(_context!)!.errorLogCleared : 'Error Log Cleared', - overflow: TextOverflow.fade, - softWrap: false, - ), - ), - ); + showSnackBar(_context != null ? AppLocalizations.of(_context!)!.errorLogCleared : 'Error Log Cleared'); } /*----------- SETUPS -----------*/ @@ -277,12 +343,21 @@ Device Parameters $deviceInfo"""; /*----------- PRINTS -----------*/ static void _print(String message) { - if (!kDebugMode) return; + if (!kDebugMode) return; // add \n every 1000 characters only if the line is longer than 1000 characters + message = message.replaceAllMapped(RegExp(r'.{1000}'), (match) => '${match.group(0)}\n'); print.i(message); } - static void _printWarning(String message) { + static void _printDebug(String message) { if (!kDebugMode) return; + // add \n every 1000 characters only if the line is longer than 1000 characters + message = message.replaceAllMapped(RegExp(r'.{1000}'), (match) => '${match.group(0)}\n'); + print.d(message); + } + + static void _printWarning(String message) { + if (!kDebugMode) return; // add \n every 1000 characters only if the line is longer than 1000 characters + message = message.replaceAllMapped(RegExp(r'.{1000}'), (match) => '${match.group(0)}\n'); print.w(message); } @@ -290,17 +365,20 @@ Device Parameters $deviceInfo"""; if (!kDebugMode) return; var message0 = DateTime.now().toString(); message0 += name != null ? ' [$name]\n' : '\n'; + message = message?.replaceAllMapped(RegExp(r'.{1000}'), (match) => '${match.group(0)}\n'); + // add \n every 1000 characters only if the line is longer than 1000 characters message0 += message ?? ''; print.e(message0, error: error, stackTrace: stackTrace); } /*----------- DISPLAY OUTPUTS -----------*/ - void _showSnackbar() { + void _showErrorSnackbar() { if (_flutterIsRunning == false) return; WidgetsBinding.instance.addPostFrameCallback((_) { globalSnackbarKey.currentState?.showSnackBar( SnackBar( + behavior: SnackBarBehavior.floating, content: Text( _context != null ? AppLocalizations.of(_context!)!.unexpectedError : 'An unexpected error occurred.', ), @@ -338,7 +416,7 @@ Device Parameters $deviceInfo"""; return text; } - String _convertLogToSingleString(String? message, {dynamic error, dynamic stackTrace, String? name, LogLevel logLevel = LogLevel.INFO}) { + String _convertLogToSingleString(String? message, {dynamic error, StackTrace? stackTrace, String? name, LogLevel logLevel = LogLevel.INFO}) { String fileMessage = '${DateTime.now().toString()}'; fileMessage += name != null ? ' [$name]\n' : '\n'; fileMessage += message ?? ''; @@ -355,12 +433,24 @@ Device Parameters $deviceInfo"""; } return fileMessage; } + + static String? _getCallerMethodName({int depth = 1}) => _getCurrentMethodName(deph: depth + 1); + static String? _getCurrentMethodName({int deph = 1}) { + final frames = StackTrace.current.toString().split('\n'); + final frame = frames.elementAtOrNull(deph + 1); + if (frame == null) return null; + final entry = frame.split(' '); + final methodName = entry.elementAtOrNull(entry.length - 2); + if (methodName == 'closure>') return RegExp(r'(?<=\s\s)\w+.*(?=\s\()').firstMatch(frame)?.group(0); + return methodName; + } } final filterParameterKeys = ['fbtoken', 'new_fb_token', 'secret']; enum LogLevel { INFO, + DEBUG, WARNING, ERROR, } diff --git a/lib/utils/pi_mailer.dart b/lib/utils/pi_mailer.dart index 8de0a3311..f0d597e62 100644 --- a/lib/utils/pi_mailer.dart +++ b/lib/utils/pi_mailer.dart @@ -1,25 +1,39 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mailer/flutter_mailer.dart'; import '../l10n/app_localizations.dart'; import '../widgets/dialog_widgets/default_dialog.dart'; -import 'app_info_utils.dart'; import 'logger.dart'; import 'view_utils.dart'; class PiMailer { - static String get _mailRecipient => 'app-crash@netknights.it'; - static String _mailSubject(String? subject, String? subjectPrefix, bool subjectAppVersion) { - String mailSubject = subjectPrefix != null ? '[$subjectPrefix] ' : ''; - if (subjectAppVersion) mailSubject += '(${AppInfoUtils.currentVersionString}+${AppInfoUtils.currentBuildNumber}) '; - mailSubject += '${AppInfoUtils.appName}'; - if (subject != null) mailSubject += ' >>> $subject'; - return mailSubject; + static String _mailSubject(String subject, String? subjectPrefix, bool subjectAppVersion) { + return subjectPrefix != null ? '$subjectPrefix $subject' : subject; } static Future sendMail({ - String? subject, + required Set mailRecipients, + required String subject, String? subjectPrefix, bool subjectAppVersion = true, required String body, @@ -29,30 +43,33 @@ class PiMailer { final MailOptions mailOptions = MailOptions( body: body, subject: _mailSubject(subject, subjectPrefix, subjectAppVersion), - recipients: [_mailRecipient], + recipients: [...mailRecipients], attachments: attachments, ); await FlutterMailer.send(mailOptions); } on PlatformException catch (e, stackTrace) { if (e.code == 'UNAVAILABLE') { showAsyncDialog( - builder: (context) => DefaultDialog( - title: Text(AppLocalizations.of(context)!.noMailAppTitle), - content: Text(AppLocalizations.of(context)!.noMailAppDescription), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ), + builder: (context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + return DefaultDialog( + title: Text(appLocalizations.noMailAppTitle), + content: Text(appLocalizations.noMailAppDescription), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(appLocalizations.ok), + ), + ], + ); + }, ); return false; } - Logger.error('Was not able to send the Email', error: e, stackTrace: stackTrace, name: 'pi_mailer.dart#sendMail'); + Logger.error('Was not able to send the Email', error: e, stackTrace: stackTrace); return false; } catch (e, stackTrace) { - Logger.error('Was not able to send the Email', error: e, stackTrace: stackTrace, name: 'pi_mailer.dart#sendMail'); + Logger.error('Was not able to send the Email', error: e, stackTrace: stackTrace); return false; } return true; diff --git a/lib/utils/riverpod/riverpod_providers/state_providers/application_customizer_provider.g.dart b/lib/utils/riverpod/riverpod_providers/state_providers/application_customizer_provider.g.dart index 9ac365271..065eeafbd 100644 --- a/lib/utils/riverpod/riverpod_providers/state_providers/application_customizer_provider.g.dart +++ b/lib/utils/riverpod/riverpod_providers/state_providers/application_customizer_provider.g.dart @@ -27,4 +27,4 @@ final applicationCustomizerProvider = AutoDisposeAsyncNotifierProvider< typedef _$ApplicationCustomizer = AutoDisposeAsyncNotifier; // ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/utils/view_utils.dart b/lib/utils/view_utils.dart index e80b08110..eb9fbb490 100644 --- a/lib/utils/view_utils.dart +++ b/lib/utils/view_utils.dart @@ -3,6 +3,30 @@ import 'package:flutter/material.dart'; import 'globals.dart'; import 'logger.dart'; +/// Shows a snackbar message to the user for 3 seconds. +ScaffoldFeatureController? showSnackBar(String message) => _showSnackBar(message, const Duration(seconds: 3)); + +/// Shows a snackbar message to the user for 10 seconds. +ScaffoldFeatureController? showSnackBarLong(String message) => _showSnackBar(message, const Duration(seconds: 10)); + +/// Shows a snackbar message to the user for a given `Duration`. +ScaffoldFeatureController? _showSnackBar( + String message, + Duration duration, +) { + if (globalSnackbarKey.currentState == null) { + Logger.warning('globalSnackbarKey.currentState is null'); + return null; + } + return globalSnackbarKey.currentState!.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text(message), + duration: const Duration(seconds: 5), + ), + ); +} + /// Shows a snackbar message to the user for a given `Duration`. void showMessage({ required String message, diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart index 4f5eb3a1e..c8c06111e 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_view.dart @@ -36,7 +36,7 @@ class _LinkInputViewState extends ConsumerState { Future addToken(Uri link) async { if (link.scheme != 'otpauth') { - ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.linkMustOtpAuth, ''); + ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.linkMustOtpAuth, null); return; } await ref.read(tokenProvider.notifier).handleLink(link); @@ -75,7 +75,7 @@ class _LinkInputViewState extends ConsumerState { onPressed: () async { ClipboardData? data = await Clipboard.getData('text/plain'); if (data == null || data.text == null || data.text!.isEmpty) { - if (context.mounted) ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.clipboardEmpty, ''); + if (context.mounted) ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.clipboardEmpty, null); return; } setState(() => textController.text = data.text ?? ''); diff --git a/lib/views/feedback_view/feedback_view.dart b/lib/views/feedback_view/feedback_view.dart index 03f57451e..5c8011438 100644 --- a/lib/views/feedback_view/feedback_view.dart +++ b/lib/views/feedback_view/feedback_view.dart @@ -1,14 +1,29 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../../../../../views/feedback_view/widgets/feedback_send_row.dart'; import '../../l10n/app_localizations.dart'; -import '../../utils/app_info_utils.dart'; import '../../utils/globals.dart'; -import '../../utils/pi_mailer.dart'; -import '../../utils/view_utils.dart'; -import '../../widgets/dialog_widgets/default_dialog.dart'; -import '../main_view/main_view.dart'; import '../view_interface.dart'; class FeedbackView extends StatefulView { @@ -25,8 +40,6 @@ class FeedbackView extends StatefulView { class _FeedbackViewState extends State { final TextEditingController _feedbackController = TextEditingController(); - bool _addDeviceInfo = false; - final FocusNode _focusNode = FocusNode(); @override @@ -61,7 +74,7 @@ class _FeedbackViewState extends State { padding: const EdgeInsets.symmetric(vertical: 16.0), child: Text( AppLocalizations.of(context)!.feedbackTitle, - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, ), ), @@ -107,86 +120,14 @@ class _FeedbackViewState extends State { ], ), ), + FeedbackSendRow(feedbackController: _feedbackController), ], ), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - onPressed: () => setState(() => _addDeviceInfo = !_addDeviceInfo), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 70, - child: Text( - AppLocalizations.of(context)!.addSystemInfo, - textAlign: TextAlign.right, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const SizedBox(width: 4), - SizedBox( - width: 24, - height: 24, - child: Checkbox( - value: _addDeviceInfo, - onChanged: (value) { - if (value == null) return; - setState(() { - _addDeviceInfo = value; - }); - }, - ), - ) - ], - ), - ), - ElevatedButton( - onPressed: () async { - final String mailText; - if (_addDeviceInfo) { - mailText = _addDeviceInfoToMail(_feedbackController.text); - } else { - mailText = _feedbackController.text; - } - final sended = await _sendMail(mailText); - if (sended) { - showAsyncDialog( - builder: (context) => DefaultDialog( - title: Text(AppLocalizations.of(context)!.feedbackSentTitle), - content: Text(AppLocalizations.of(context)!.feedbackSentDescription), - actionsAlignment: MainAxisAlignment.center, - actions: [ - ElevatedButton( - onPressed: () => Navigator.of(context).popUntil((route) => route.settings.name == MainView.routeName), - child: Text(AppLocalizations.of(context)!.ok)) - ], - ), - barrierDismissible: false, - ); - } - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(AppLocalizations.of(context)!.send), - const SizedBox(width: 8), - const Icon(Icons.mail_outline), - ], - ), - ), - ], - ), ], ), ), ), ); - - String _addDeviceInfoToMail(String feedback) => '$feedback\n\n[${AppInfoUtils.currentVersionAndBuildNumber}] ${AppInfoUtils.deviceInfoString}'; - Future _sendMail(String mailText) => PiMailer.sendMail(subjectPrefix: 'Feedback', body: mailText, subjectAppVersion: false); } diff --git a/lib/views/feedback_view/widgets/feedback_send_row.dart b/lib/views/feedback_view/widgets/feedback_send_row.dart new file mode 100644 index 000000000..193f6a0ae --- /dev/null +++ b/lib/views/feedback_view/widgets/feedback_send_row.dart @@ -0,0 +1,147 @@ +/* + * privacyIDEA Authenticator + * + * Author: Frank Merkel + * + * Copyright (c) 2024 NetKnights GmbH + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:privacyidea_authenticator/mains/main_netknights.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../utils/app_info_utils.dart'; +import '../../../utils/pi_mailer.dart'; +import '../../../utils/view_utils.dart'; +import '../../../widgets/dialog_widgets/default_dialog.dart'; + +class FeedbackSendRow extends StatefulWidget { + final TextEditingController feedbackController; + + const FeedbackSendRow({super.key, required this.feedbackController}); + + @override + State createState() => _FeedbackSendRowState(); +} + +class _FeedbackSendRowState extends State { + late final _feedbackController = widget.feedbackController; + bool _addDeviceInfo = false; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Expanded(child: SizedBox()), + Expanded( + flex: 4, + child: TextButton( + onPressed: () => setState(() => _addDeviceInfo = !_addDeviceInfo), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + AppLocalizations.of(context)!.addSystemInfo, + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: _addDeviceInfo, + onChanged: (value) { + if (value == null) return; + setState(() { + _addDeviceInfo = value; + }); + }), + ), + ], + ), + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ), + Expanded( + child: Center( + child: ElevatedButton( + onPressed: () async { + final String mailText; + if (_addDeviceInfo) { + mailText = _addDeviceInfoToMail(_feedbackController.text); + } else { + mailText = _feedbackController.text; + } + final sended = await _sendMail(mailText); + if (sended) { + showAsyncDialog( + builder: (context) => DefaultDialog( + title: Text(AppLocalizations.of(context)!.feedbackSentTitle), + content: Text(AppLocalizations.of(context)!.feedbackSentDescription), + actionsAlignment: MainAxisAlignment.center, + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst), + child: Text(AppLocalizations.of(context)!.ok), + ) + ], + ), + barrierDismissible: false, + ); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppLocalizations.of(context)!.send), + const SizedBox(width: 8), + const Icon(Icons.mail_outline), + ], + ), + ), + ), + ), + ], + ); + } + + String _addDeviceInfoToMail(String feedback) => '$feedback\n\n[${AppInfoUtils.currentVersionAndBuildNumber}] ${AppInfoUtils.deviceInfoString}'; + Future _sendMail(String mailText) => PiMailer.sendMail( + mailRecipients: _mailRecipients, + subjectPrefix: PrivacyIDEAAuthenticator.currentCustomization?.feedbackSubjectPrefix, + subject: '', + body: mailText, + subjectAppVersion: false, + ); + + Set get _mailRecipients => + PrivacyIDEAAuthenticator.currentCustomization != null ? {PrivacyIDEAAuthenticator.currentCustomization!.feedbackRecipient} : {}; +} diff --git a/lib/views/splash_screen/splash_screen.dart b/lib/views/splash_screen/splash_screen.dart index 4d52574de..ea0f6d663 100644 --- a/lib/views/splash_screen/splash_screen.dart +++ b/lib/views/splash_screen/splash_screen.dart @@ -74,7 +74,7 @@ class _SplashScreenState extends ConsumerState { } final ViewWidget nextView = MainView( appName: _customization.appName, - appIcon: _customization.appIcon.getWidget, + appIcon: _customization.appbarIcon.getWidget, disablePatchNotes: _customization.disabledFeatures.contains(AppFeature.patchNotes), ); final routeBuilder = PageRouteBuilder(pageBuilder: (_, __, ___) => nextView); @@ -104,7 +104,7 @@ class _SplashScreenState extends ConsumerState { child: SizedBox( height: 99999, width: 99999, - child: _customization.appImage.getWidget, + child: _customization.splashScreenImage.getWidget, ), ), ), diff --git a/test/unit_test/utils/customization/application_customization_test.dart b/test/unit_test/utils/customization/application_customization_test.dart index 6b341c740..560799bd7 100644 --- a/test/unit_test/utils/customization/application_customization_test.dart +++ b/test/unit_test/utils/customization/application_customization_test.dart @@ -17,13 +17,15 @@ void _testAppCustomizer() { final customization = ApplicationCustomization( appName: 'test', websiteLink: 'https://test', - appIcon: WidgetImage( + appbarIcon: WidgetImage( fileType: ImageFileType.png, imageData: defaultIconUint8List, + fileName: "appbarIcon", ), - appImage: WidgetImage( + splashScreenImage: WidgetImage( fileType: ImageFileType.png, imageData: defaultImageUint8List, + fileName: "splashScreenImage", ), lightTheme: ApplicationCustomization.defaultCustomization.lightTheme, darkTheme: ApplicationCustomization.defaultCustomization.darkTheme, @@ -33,12 +35,12 @@ void _testAppCustomizer() { // Assert expect(customization.appName, equals('test')); expect(customization.websiteLink, equals('https://test')); - expect(customization.appIcon.imageData, equals(defaultIconUint8List)); - expect(() => customization.appIcon.getWidget, returnsNormally); - expect(customization.appImage.getWidget, isA()); - expect(customization.appImage.imageData, equals(defaultImageUint8List)); - expect(() => customization.appImage.getWidget, returnsNormally); - expect(customization.appImage.getWidget, isA()); + expect(customization.appbarIcon.imageData, equals(defaultIconUint8List)); + expect(() => customization.appbarIcon.getWidget, returnsNormally); + expect(customization.splashScreenImage.getWidget, isA()); + expect(customization.splashScreenImage.imageData, equals(defaultImageUint8List)); + expect(() => customization.splashScreenImage.getWidget, returnsNormally); + expect(customization.splashScreenImage.getWidget, isA()); expect(customization.lightTheme, equals(ApplicationCustomization.defaultCustomization.lightTheme)); expect(customization.darkTheme, equals(ApplicationCustomization.defaultCustomization.darkTheme)); expect(customization.disabledFeatures, equals({AppFeature.patchNotes})); @@ -48,13 +50,15 @@ void _testAppCustomizer() { final newCustomization = customization.copyWith( appName: 'test2', websiteLink: 'https://test2', - appIcon: WidgetImage( + appbarIcon: WidgetImage( fileType: ImageFileType.png, imageData: defaultImageUint8List, + fileName: "appbarIcon", ), - appImage: WidgetImage( + splashScreenImage: WidgetImage( fileType: ImageFileType.png, imageData: defaultIconUint8List, + fileName: "appImage", ), lightTheme: ApplicationCustomization.defaultCustomization.darkTheme, darkTheme: ApplicationCustomization.defaultCustomization.lightTheme, @@ -63,8 +67,8 @@ void _testAppCustomizer() { // Assert expect(newCustomization.appName, equals('test2')); expect(newCustomization.websiteLink, equals('https://test2')); - expect(newCustomization.appIcon.imageData, equals(defaultImageUint8List)); - expect(newCustomization.appImage.imageData, equals(defaultIconUint8List)); + expect(newCustomization.appbarIcon.imageData, equals(defaultImageUint8List)); + expect(newCustomization.splashScreenImage.imageData, equals(defaultIconUint8List)); }); group('serialization', () { test('toJson', () { @@ -93,8 +97,8 @@ void _testAppCustomizer() { // Assert expect(newCustomization.appName, equals('test2')); expect(newCustomization.websiteLink, equals('https://test2')); - expect(newCustomization.appIcon.imageData, equals(defaultIconUint8List)); - expect(newCustomization.appImage.imageData, equals(defaultImageUint8List)); + expect(newCustomization.appbarIcon.imageData, equals(defaultIconUint8List)); + expect(newCustomization.splashScreenImage.imageData, equals(defaultImageUint8List)); expect(newCustomization.lightTheme, equals(ApplicationCustomization.defaultCustomization.lightTheme)); expect(newCustomization.darkTheme, equals(ApplicationCustomization.defaultCustomization.darkTheme)); expect(newCustomization.disabledFeatures, isA()); From dba5834b70b3f284c31681dc15a6f84802d1be62 Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:51:51 +0100 Subject: [PATCH 2/5] Update version number to 4.4.2+404301 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 799e70a7f..a05a4c18f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 4.4.2+404206 # TODO Set the right version number +version: 4.4.2+404301 # TODO Set the right version number # version: major.minor.build + 2x major|2x minor|3x build # version: version number + build number (optional) From 5746307f368cba738f9c7fad057a49413d554e4f Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:21:46 +0100 Subject: [PATCH 3/5] Bump version number to 4.4.3+404301 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index a05a4c18f..77897685f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 4.4.2+404301 # TODO Set the right version number +version: 4.4.3+404301 # TODO Set the right version number # version: major.minor.build + 2x major|2x minor|3x build # version: version number + build number (optional) From ce92c193e1b1a26dc5e776abce815f9ea0823d30 Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:26:47 +0100 Subject: [PATCH 4/5] Refactor token notifier methods, enhance theme customization, and update clipboard handling --- lib/state_notifiers/token_notifier.dart | 4 ++-- lib/utils/customization/theme_customization.dart | 1 + .../add_token_manually_view_widgets/link_input_field.dart | 2 +- lib/widgets/app_wrapper.dart | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/state_notifiers/token_notifier.dart b/lib/state_notifiers/token_notifier.dart index 68d9523eb..2d568140b 100644 --- a/lib/state_notifiers/token_notifier.dart +++ b/lib/state_notifiers/token_notifier.dart @@ -11,11 +11,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; import 'package:mutex/mutex.dart'; import 'package:pointycastle/asymmetric/api.dart'; -import '../model/enums/token_import_type.dart'; import '../interfaces/repo/token_repository.dart'; import '../l10n/app_localizations.dart'; import '../model/enums/push_token_rollout_state.dart'; +import '../model/enums/token_import_type.dart'; import '../model/enums/token_origin_source_type.dart'; import '../model/extensions/enums/push_token_rollout_state_extension.dart'; import '../model/extensions/enums/token_origin_source_type.dart'; @@ -375,7 +375,7 @@ class TokenNotifier extends StateNotifier { hideLockedTokens.add(token); } } - return await updateTokens(hideLockedTokens, (p0) => p0.copyWith(isHidden: true)); + return await _updateTokens(hideLockedTokens, (p0) => p0.copyWith(isHidden: true)); } Future removeToken(Token token) async { diff --git a/lib/utils/customization/theme_customization.dart b/lib/utils/customization/theme_customization.dart index 9109e9d7f..e758ca0c1 100644 --- a/lib/utils/customization/theme_customization.dart +++ b/lib/utils/customization/theme_customization.dart @@ -334,6 +334,7 @@ class ThemeCustomization { labelLarge: TextStyle(color: foregroundColor, fontFamily: fontFamily), labelSmall: TextStyle(color: foregroundColor, fontFamily: fontFamily), ), + disabledColor: foregroundColor.withOpacity(0.38), // 38% opacity used for disabled icon buttons iconButtonTheme: IconButtonThemeData( style: ButtonStyle( foregroundColor: WidgetStateProperty.all(foregroundColor), diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart index 64a4f7ee7..f92afdabe 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/link_input_field.dart @@ -73,7 +73,7 @@ class _LinkInputViewState extends ConsumerState { onPressed: () async { ClipboardData? data = await Clipboard.getData('text/plain'); if (data == null || data.text == null || data.text!.isEmpty) { - if (context.mounted) ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.clipboardEmpty, ''); + if (context.mounted) ref.read(statusMessageProvider.notifier).state = (AppLocalizations.of(context)!.clipboardEmpty, null); return; } setState(() => textController.text = data.text ?? ''); diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index ff6e1d901..ad10ee144 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -53,7 +53,7 @@ class _AppWrapperState extends ConsumerState<_AppWrapper> { if (await ref.read(tokenProvider.notifier).saveStateOnMinimizeApp() == false) { Logger.error('Failed to save tokens on Hide', name: 'tokenProvider#appStateProvider'); } - if (ref.read(tokenFolderProvider.notifier).collapseLockedFolders() == false) { + if (await ref.read(tokenFolderProvider.notifier).collapseLockedFolders() == false) { Logger.error('Failed to collapse locked folders on Hide', name: 'tokenFolderProvider#appStateProvider'); } await FlutterLocalNotificationsPlugin().cancelAll(); From 7d626d0c99cc999696432db2ba5ed46bdd723f04 Mon Sep 17 00:00:00 2001 From: Frank Merkel <138444693+frankmer@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:00:38 +0100 Subject: [PATCH 5/5] Remove commented-out code for crash report recipients in Logger class --- lib/utils/logger.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 30af83678..7c261b3cf 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -50,7 +50,7 @@ class Logger { static BuildContext? get _context => navigatorKey.currentContext; static String get _mailBody => _context != null ? AppLocalizations.of(_context!)!.errorMailBody : 'Error Log File Attached'; static Set get _mailRecipients => { - ...globalRef?.read(settingsProvider).crashReportRecipients ?? {}, + // ...globalRef?.read(settingsProvider).crashReportRecipients ?? {}, ...PrivacyIDEAAuthenticator.currentCustomization != null ? {PrivacyIDEAAuthenticator.currentCustomization!.crashRecipient} : {} }; static printer.Logger print = printer.Logger(