diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 5cf21e1dd6..2e74f6153a 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -67,7 +67,7 @@ custom_lint: - lib/entities/*.entity.dart - lib/repositories/{album,asset,backup,database,etag,exif_info,user,timeline,partner}.repository.dart - lib/infrastructure/entities/*.entity.dart - - lib/infrastructure/repositories/{store,db}.repository.dart + - lib/infrastructure/repositories/{store,db,log}.repository.dart - lib/providers/infrastructure/db.provider.dart # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index cc0e7ca215..868b036d1b 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -1,3 +1,6 @@ const int noDbId = -9223372036854775808; // from Isar const double downloadCompleted = -1; const double downloadFailed = -2; + +// Number of log entries to retain on app start +const int kLogTruncateLimit = 250; diff --git a/mobile/lib/domain/interfaces/log.interface.dart b/mobile/lib/domain/interfaces/log.interface.dart new file mode 100644 index 0000000000..f1cbc977dd --- /dev/null +++ b/mobile/lib/domain/interfaces/log.interface.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/models/log.model.dart'; + +abstract interface class ILogRepository { + Future insert(LogMessage log); + + Future insertAll(Iterable logs); + + Future> getAll(); + + Future deleteAll(); + + /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs + Future truncate({int limit = 250}); +} diff --git a/mobile/lib/domain/models/log.model.dart b/mobile/lib/domain/models/log.model.dart new file mode 100644 index 0000000000..51f816df01 --- /dev/null +++ b/mobile/lib/domain/models/log.model.dart @@ -0,0 +1,69 @@ +// ignore_for_file: constant_identifier_names + +import 'package:logging/logging.dart'; + +/// Log levels according to dart logging [Level] +enum LogLevel { + ALL, + FINEST, + FINER, + FINE, + CONFIG, + INFO, + WARNING, + SEVERE, + SHOUT, + OFF, +} + +class LogMessage { + final String message; + final LogLevel level; + final DateTime createdAt; + final String? logger; + final String? error; + final String? stack; + + const LogMessage({ + required this.message, + required this.level, + required this.createdAt, + this.logger, + this.error, + this.stack, + }); + + @override + bool operator ==(covariant LogMessage other) { + if (identical(this, other)) return true; + + return other.message == message && + other.level == level && + other.createdAt == createdAt && + other.logger == logger && + other.error == error && + other.stack == stack; + } + + @override + int get hashCode { + return message.hashCode ^ + level.hashCode ^ + createdAt.hashCode ^ + logger.hashCode ^ + error.hashCode ^ + stack.hashCode; + } + + @override + String toString() { + return '''LogMessage: { +message: $message, +level: $level, +createdAt: $createdAt, +logger: ${logger ?? ''}, +error: ${error ?? ''}, +stack: ${stack ?? ''}, +}'''; + } +} diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart new file mode 100644 index 0000000000..61b5638e78 --- /dev/null +++ b/mobile/lib/domain/services/log.service.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:logging/logging.dart'; + +class LogService { + final ILogRepository _logRepository; + final IStoreRepository _storeRepository; + + final List _msgBuffer = []; + + /// Whether to buffer logs in memory before writing to the database. + /// This is useful when logging in quick succession, as it increases performance + /// and reduces NAND wear. However, it may cause the logs to be lost in case of a crash / in isolates. + final bool _shouldBuffer; + Timer? _flushTimer; + + late final StreamSubscription _logSubscription; + + LogService._( + this._logRepository, + this._storeRepository, + this._shouldBuffer, + ) { + // Listen to log messages and write them to the database + _logSubscription = Logger.root.onRecord.listen(_writeLogToDatabase); + } + + static LogService? _instance; + static LogService get I { + if (_instance == null) { + throw const LoggerUnInitializedException(); + } + return _instance!; + } + + static Future init({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + if (_instance != null) { + return _instance!; + } + _instance = await create( + logRepo: logRepo, + storeRepo: storeRepo, + shouldBuffer: shouldBuffer, + ); + return _instance!; + } + + static Future create({ + required ILogRepository logRepo, + required IStoreRepository storeRepo, + bool shouldBuffer = true, + }) async { + final instance = LogService._(logRepo, storeRepo, shouldBuffer); + // Truncate logs to 250 + await logRepo.truncate(limit: kLogTruncateLimit); + // Get log level from store + final level = await instance._storeRepository.tryGet(StoreKey.logLevel); + if (level != null) { + Logger.root.level = Level.LEVELS.elementAtOrNull(level) ?? Level.INFO; + } + return instance; + } + + Future setlogLevel(LogLevel level) async { + await _storeRepository.insert(StoreKey.logLevel, level.index); + Logger.root.level = level.toLevel(); + } + + Future> getMessages() async { + final logsFromDb = await _logRepository.getAll(); + if (_msgBuffer.isNotEmpty) { + return [..._msgBuffer.reversed, ...logsFromDb]; + } + return logsFromDb; + } + + Future clearLogs() async { + _flushTimer?.cancel(); + _flushTimer = null; + _msgBuffer.clear(); + await _logRepository.deleteAll(); + } + + /// Flush pending log messages to persistent storage + Future flush() async { + if (_flushTimer == null) { + return; + } + _flushTimer!.cancel(); + await _flushBufferToDatabase(); + } + + Future dispose() { + _flushTimer?.cancel(); + _logSubscription.cancel(); + return _flushBufferToDatabase(); + } + + void _writeLogToDatabase(LogRecord r) { + final record = LogMessage( + message: r.message, + level: r.level.toLogLevel(), + createdAt: r.time, + logger: r.loggerName, + error: r.error?.toString(), + stack: r.stackTrace?.toString(), + ); + + if (_shouldBuffer) { + _msgBuffer.add(record); + _flushTimer ??= Timer( + const Duration(seconds: 5), + () => unawaited(_flushBufferToDatabase()), + ); + } else { + unawaited(_logRepository.insert(record)); + } + } + + Future _flushBufferToDatabase() async { + _flushTimer = null; + final buffer = [..._msgBuffer]; + _msgBuffer.clear(); + await _logRepository.insertAll(buffer); + } +} + +class LoggerUnInitializedException implements Exception { + const LoggerUnInitializedException(); + + @override + String toString() => 'Logger is not initialized. Call init()'; +} + +/// Log levels according to dart logging [Level] +extension LevelDomainToInfraExtension on Level { + LogLevel toLogLevel() => + LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? + LogLevel.INFO; +} + +extension on LogLevel { + Level toLevel() => Level.LEVELS.elementAtOrNull(index) ?? Level.INFO; +} diff --git a/mobile/lib/entities/logger_message.entity.dart b/mobile/lib/entities/logger_message.entity.dart deleted file mode 100644 index d904e19e7a..0000000000 --- a/mobile/lib/entities/logger_message.entity.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; - -part 'logger_message.entity.g.dart'; - -@Collection(inheritance: false) -class LoggerMessage { - Id id = Isar.autoIncrement; - String message; - String? details; - @Enumerated(EnumType.ordinal) - LogLevel level = LogLevel.INFO; - DateTime createdAt; - String? context1; - String? context2; - - LoggerMessage({ - required this.message, - required this.details, - required this.level, - required this.createdAt, - required this.context1, - required this.context2, - }); - - @override - String toString() { - return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; - } -} - -/// Log levels according to dart logging [Level] -enum LogLevel { - ALL, - FINEST, - FINER, - FINE, - CONFIG, - INFO, - WARNING, - SEVERE, - SHOUT, - OFF, -} - -extension LevelExtension on Level { - LogLevel toLogLevel() => LogLevel.values[Level.LEVELS.indexOf(this)]; -} diff --git a/mobile/lib/infrastructure/entities/log.entity.dart b/mobile/lib/infrastructure/entities/log.entity.dart new file mode 100644 index 0000000000..6c55f17989 --- /dev/null +++ b/mobile/lib/infrastructure/entities/log.entity.dart @@ -0,0 +1,52 @@ +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:isar/isar.dart'; + +part 'log.entity.g.dart'; + +@Collection(inheritance: false) +class LoggerMessage { + Id id = Isar.autoIncrement; + String message; + String? details; + @Enumerated(EnumType.ordinal) + LogLevel level = LogLevel.INFO; + DateTime createdAt; + String? context1; + String? context2; + + LoggerMessage({ + required this.message, + required this.details, + required this.level, + required this.createdAt, + required this.context1, + required this.context2, + }); + + @override + String toString() { + return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)'; + } + + LogMessage toDto() { + return LogMessage( + message: message, + level: level, + createdAt: createdAt, + logger: context1, + error: details, + stack: context2, + ); + } + + static LoggerMessage fromDto(LogMessage log) { + return LoggerMessage( + message: log.message, + details: log.error, + level: log.level, + createdAt: log.createdAt, + context1: log.logger, + context2: log.stack, + ); + } +} diff --git a/mobile/lib/entities/logger_message.entity.g.dart b/mobile/lib/infrastructure/entities/log.entity.g.dart similarity index 99% rename from mobile/lib/entities/logger_message.entity.g.dart rename to mobile/lib/infrastructure/entities/log.entity.g.dart index e292e7173a..f3ee284aa4 100644 --- a/mobile/lib/entities/logger_message.entity.g.dart +++ b/mobile/lib/infrastructure/entities/log.entity.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'logger_message.entity.dart'; +part of 'log.entity.dart'; // ************************************************************************** // IsarCollectionGenerator diff --git a/mobile/lib/infrastructure/repositories/log.repository.dart b/mobile/lib/infrastructure/repositories/log.repository.dart new file mode 100644 index 0000000000..6ff128f93b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/log.repository.dart @@ -0,0 +1,53 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarLogRepository extends IsarDatabaseRepository + implements ILogRepository { + final Isar _db; + const IsarLogRepository(super.db) : _db = db; + + @override + Future deleteAll() async { + await transaction(() async => await _db.loggerMessages.clear()); + return true; + } + + @override + Future> getAll() async { + final logs = + await _db.loggerMessages.where().sortByCreatedAtDesc().findAll(); + return logs.map((l) => l.toDto()).toList(); + } + + @override + Future insert(LogMessage log) async { + final logEntity = LoggerMessage.fromDto(log); + await transaction(() async { + await _db.loggerMessages.put(logEntity); + }); + return true; + } + + @override + Future insertAll(Iterable logs) async { + await transaction(() async { + final logEntities = + logs.map((log) => LoggerMessage.fromDto(log)).toList(); + await _db.loggerMessages.putAll(logEntities); + }); + return true; + } + + @override + Future truncate({int limit = 250}) async { + await transaction(() async { + final count = await _db.loggerMessages.count(); + if (count <= limit) return; + final toRemove = count - limit; + await _db.loggerMessages.where().limit(toRemove).deleteAll(); + }); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index da84a8cff6..407ea86d59 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; @@ -67,9 +66,6 @@ Future initApp() async { await DynamicTheme.fetchSystemPalette(); - // Initialize Immich Logger Service - ImmichLogger(); - final log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 226d380a28..3bd2e0111f 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -2,10 +2,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:intl/intl.dart'; @@ -17,8 +18,11 @@ class AppLogPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final immichLogger = ImmichLogger(); - final logMessages = useState(immichLogger.messages); + final immichLogger = LogService.I; + final shouldReload = useState(false); + final logMessages = useFuture( + useMemoized(() => immichLogger.getMessages(), [shouldReload.value]), + ); Widget colorStatusIndicator(Color color) { return Column( @@ -71,7 +75,7 @@ class AppLogPage extends HookConsumerWidget { ), onPressed: () { immichLogger.clearLogs(); - logMessages.value = []; + shouldReload.value = !shouldReload.value; }, ), Builder( @@ -84,7 +88,7 @@ class AppLogPage extends HookConsumerWidget { size: 20.0, ), onPressed: () { - immichLogger.shareLogs(iconContext); + ImmichLogger.shareLogs(iconContext); }, ); }, @@ -105,9 +109,9 @@ class AppLogPage extends HookConsumerWidget { separatorBuilder: (context, index) { return const Divider(height: 0); }, - itemCount: logMessages.value.length, + itemCount: logMessages.data?.length ?? 0, itemBuilder: (context, index) { - var logMessage = logMessages.value[index]; + var logMessage = logMessages.data![index]; return ListTile( onTap: () => context.pushRoute( AppLogDetailRoute( @@ -128,7 +132,7 @@ class AppLogPage extends HookConsumerWidget { ), ), subtitle: Text( - "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}", + "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.logger}", style: TextStyle( fontSize: 12.0, color: context.colorScheme.onSurfaceSecondary, diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index dd6af81728..1bfea44ba1 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -1,15 +1,15 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; @RoutePage() class AppLogDetailPage extends HookConsumerWidget { const AppLogDetailPage({super.key, required this.logMessage}); - final LoggerMessage logMessage; + final LogMessage logMessage; @override Widget build(BuildContext context, WidgetRef ref) { @@ -126,14 +126,14 @@ class AppLogDetailPage extends HookConsumerWidget { child: ListView( children: [ buildTextWithCopyButton("MESSAGE", logMessage.message), - if (logMessage.details != null) - buildTextWithCopyButton("DETAILS", logMessage.details.toString()), - if (logMessage.context1 != null) - buildLogContext1(logMessage.context1.toString()), - if (logMessage.context2 != null) + if (logMessage.error != null) + buildTextWithCopyButton("DETAILS", logMessage.error.toString()), + if (logMessage.logger != null) + buildLogContext1(logMessage.logger.toString()), + if (logMessage.stack != null) buildTextWithCopyButton( "STACK TRACE", - logMessage.context2.toString(), + logMessage.stack.toString(), ), ], ), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 780e22b818..92c199ab76 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,20 +1,22 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:permission_handler/permission_handler.dart'; enum AppLifeCycleEnum { @@ -112,7 +114,7 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(websocketProvider.notifier).disconnect(); } - ImmichLogger().flush(); + unawaited(LogService.I.flush()); } void handleAppDetached() { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 66a65f559e..ae5419b712 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -1,44 +1,48 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; -import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; -import 'package:immich_mobile/pages/backup/backup_options.page.dart'; -import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; -import 'package:immich_mobile/pages/albums/albums.page.dart'; -import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; -import 'package:immich_mobile/pages/library/local_albums.page.dart'; -import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; -import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; -import 'package:immich_mobile/pages/library/library.page.dart'; -import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; import 'package:immich_mobile/pages/album/album_options.page.dart'; import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/album/album_viewer.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; +import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/settings.page.dart'; import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; -import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -54,10 +58,6 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner.page.dart'; -import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e4f1190510..299c8a602f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -386,7 +386,7 @@ class AllVideosRoute extends PageRouteInfo { class AppLogDetailRoute extends PageRouteInfo { AppLogDetailRoute({ Key? key, - required LoggerMessage logMessage, + required LogMessage logMessage, List? children, }) : super( AppLogDetailRoute.name, @@ -419,7 +419,7 @@ class AppLogDetailRouteArgs { final Key? key; - final LoggerMessage logMessage; + final LogMessage logMessage; @override String toString() { diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index 952e8b191e..fab4b9966a 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,11 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -18,75 +14,10 @@ import 'package:share_plus/share_plus.dart'; /// /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// and generate a csv file. -class ImmichLogger { - static final ImmichLogger _instance = ImmichLogger._internal(); - final maxLogEntries = 500; - final Isar _db = Isar.getInstance()!; - List _msgBuffer = []; - Timer? _timer; +abstract final class ImmichLogger { + const ImmichLogger(); - factory ImmichLogger() => _instance; - - ImmichLogger._internal() { - _removeOverflowMessages(); - final int levelId = Store.get(StoreKey.logLevel, 5); // 5 is INFO - Logger.root.level = Level.LEVELS[levelId]; - Logger.root.onRecord.listen(_writeLogToDatabase); - } - - set level(Level level) => Logger.root.level = level; - - List get messages { - final inDb = - _db.loggerMessages.where(sort: Sort.desc).anyId().findAllSync(); - return _msgBuffer.isEmpty ? inDb : _msgBuffer.reversed.toList() + inDb; - } - - void _removeOverflowMessages() { - final msgCount = _db.loggerMessages.countSync(); - if (msgCount > maxLogEntries) { - final numberOfEntryToBeDeleted = msgCount - maxLogEntries; - _db.writeTxn( - () => _db.loggerMessages - .where() - .limit(numberOfEntryToBeDeleted) - .deleteAll(), - ); - } - } - - void _writeLogToDatabase(LogRecord record) { - debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); - final lm = LoggerMessage( - message: record.message, - details: record.error?.toString(), - level: record.level.toLogLevel(), - createdAt: record.time, - context1: record.loggerName, - context2: record.stackTrace?.toString(), - ); - _msgBuffer.add(lm); - - // delayed batch writing to database: increases performance when logging - // messages in quick succession and reduces NAND wear - _timer ??= Timer(const Duration(seconds: 5), _flushBufferToDatabase); - } - - void _flushBufferToDatabase() { - _timer = null; - final buffer = _msgBuffer; - _msgBuffer = []; - _db.writeTxn(() => _db.loggerMessages.putAll(buffer)); - } - - void clearLogs() { - _timer?.cancel(); - _timer = null; - _msgBuffer.clear(); - _db.writeTxn(() => _db.loggerMessages.clear()); - } - - Future shareLogs(BuildContext context) async { + static Future shareLogs(BuildContext context) async { final tempDir = await getTemporaryDirectory(); final dateTime = DateTime.now().toIso8601String(); final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; @@ -94,13 +25,13 @@ class ImmichLogger { final io = logFile.openWrite(); try { // Write messages - for (final m in messages) { + for (final m in await LogService.I.getMessages()) { final created = m.createdAt; final level = m.level.name.padRight(8); - final logger = (m.context1 ?? "").padRight(20); + final logger = (m.logger ?? "").padRight(20); final message = m.message; - final error = m.details != null ? " ${m.details} |" : ""; - final stack = m.context2 != null ? "\n${m.context2!}" : ""; + final error = m.error == null ? "" : " ${m.error} |"; + final stack = m.stack == null ? "" : "\n${m.stack!}"; io.write('$created | $level | $logger | $message |$error$stack\n'); } } finally { @@ -115,16 +46,6 @@ class ImmichLogger { [XFile(filePath)], subject: "Immich logs $dateTime", sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ).then( - (value) => logFile.delete(), - ); - } - - /// Flush pending log messages to persistent storage - void flush() { - if (_timer != null) { - _timer!.cancel(); - _db.writeTxnSync(() => _db.loggerMessages.putAllSync(_msgBuffer)); - } + ).then((value) => logFile.delete()); } } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 32bdac42d5..5b9a41f28d 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; @@ -10,9 +11,10 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; @@ -46,5 +48,9 @@ abstract final class Bootstrap { static Future initDomain(Isar db) async { await StoreService.init(storeRepository: IsarStoreRepository(db)); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); } } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index ec1ab79cf7..4e399e8aec 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,18 +1,19 @@ import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart'; import 'package:logging/logging.dart'; @@ -33,7 +34,8 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => ImmichLogger().level = Level.LEVELS[levelId.value], + (_, __) => + LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); final advancedSettings = [ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 5a15bf5f5e..08c71e36f8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -407,7 +407,7 @@ packages: source: hosted version: "0.0.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1191612363..89e7b09ca4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -113,6 +113,7 @@ dev_dependencies: mocktail: ^1.0.3 immich_mobile_immich_lint: path: './immich_lint' + fake_async: ^1.3.1 flutter: uses-material-design: true diff --git a/mobile/test/domain/services/log_service_test.dart b/mobile/test/domain/services/log_service_test.dart new file mode 100644 index 0000000000..cbceb0d165 --- /dev/null +++ b/mobile/test/domain/services/log_service_test.dart @@ -0,0 +1,186 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/interfaces/store.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:logging/logging.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../infrastructure/repository.mock.dart'; +import '../../test_utils.dart'; + +final _kInfoLog = LogMessage( + message: '#Info Message', + level: LogLevel.INFO, + createdAt: DateTime(2025, 2, 26), + logger: 'Info Logger', +); + +final _kWarnLog = LogMessage( + message: '#Warn Message', + level: LogLevel.WARNING, + createdAt: DateTime(2025, 2, 27), + logger: 'Warn Logger', +); + +void main() { + late LogService sut; + late ILogRepository mockLogRepo; + late IStoreRepository mockStoreRepo; + + setUp(() async { + mockLogRepo = MockLogRepository(); + mockStoreRepo = MockStoreRepository(); + + registerFallbackValue(_kInfoLog); + + when(() => mockLogRepo.truncate(limit: any(named: 'limit'))) + .thenAnswer((_) async => {}); + when(() => mockStoreRepo.tryGet(StoreKey.logLevel)) + .thenAnswer((_) async => LogLevel.FINE.index); + when(() => mockLogRepo.getAll()).thenAnswer((_) async => []); + when(() => mockLogRepo.insert(any())).thenAnswer((_) async => true); + when(() => mockLogRepo.insertAll(any())).thenAnswer((_) async => true); + + sut = + await LogService.create(logRepo: mockLogRepo, storeRepo: mockStoreRepo); + }); + + tearDown(() async { + await sut.dispose(); + }); + + group("Log Service Init:", () { + test('Truncates the existing logs on init', () { + final limit = + verify(() => mockLogRepo.truncate(limit: captureAny(named: 'limit'))) + .captured + .firstOrNull as int?; + expect(limit, kLogTruncateLimit); + }); + + test('Sets log level based on the store setting', () { + verify(() => mockStoreRepo.tryGet(StoreKey.logLevel)).called(1); + expect(Logger.root.level, Level.FINE); + }); + }); + + group("Log Service Set Level:", () { + setUp(() async { + when(() => mockStoreRepo.insert(StoreKey.logLevel, any())) + .thenAnswer((_) async => true); + await sut.setlogLevel(LogLevel.SHOUT); + }); + + test('Updates the log level in store', () { + final index = verify( + () => mockStoreRepo.insert(StoreKey.logLevel, captureAny()), + ).captured.firstOrNull; + expect(index, LogLevel.SHOUT.index); + }); + + test('Sets log level on logger', () { + expect(Logger.root.level, Level.SHOUT); + }); + }); + + group("Log Service Buffer:", () { + test('Buffers logs until timer elapses', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + expect(await sut.getMessages(), hasLength(1)); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); + time.elapse(const Duration(seconds: 6)); + expect(await sut.getMessages(), isEmpty); + }); + }); + + test('Batch inserts all logs on timer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + time.elapse(const Duration(seconds: 6)); + final insert = verify(() => mockLogRepo.insertAll(captureAny())); + insert.called(1); + // ignore: prefer-correct-json-casts + final captured = insert.captured.firstOrNull as List; + expect(captured.firstOrNull?.message, _kInfoLog.message); + expect(captured.firstOrNull?.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insert(captureAny())); + }); + }); + + test('Does not buffer when off', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: false, + ); + + final logger = Logger(_kInfoLog.logger!); + logger.info(_kInfoLog.message); + // Ensure nothing gets buffer. This works because we mock log repo getAll to return nothing + expect(await sut.getMessages(), isEmpty); + + final insert = verify(() => mockLogRepo.insert(captureAny())); + insert.called(1); + final captured = insert.captured.firstOrNull as LogMessage; + expect(captured.message, _kInfoLog.message); + expect(captured.logger, _kInfoLog.logger); + + verifyNever(() => mockLogRepo.insertAll(captureAny())); + }); + }); + }); + + group("Log Service Get messages:", () { + setUp(() { + when(() => mockLogRepo.getAll()).thenAnswer((_) async => [_kInfoLog]); + }); + + test('Fetches result from DB', () async { + expect(await sut.getMessages(), hasLength(1)); + verify(() => mockLogRepo.getAll()).called(1); + }); + + test('Combines result from both DB + Buffer', () { + TestUtils.fakeAsync((time) async { + sut = await LogService.create( + logRepo: mockLogRepo, + storeRepo: mockStoreRepo, + shouldBuffer: true, + ); + + final logger = Logger(_kWarnLog.logger!); + logger.warning(_kWarnLog.message); + expect(await sut.getMessages(), hasLength(2)); // 1 - DB, 1 - Buff + + final messages = await sut.getMessages(); + // Logged time is assigned in the service for messages in the buffer, so compare manually + expect(messages.firstOrNull?.message, _kWarnLog.message); + expect(messages.firstOrNull?.logger, _kWarnLog.logger); + + expect(messages.elementAtOrNull(1), _kInfoLog); + }); + }); + }); +} diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index ff25bdac9d..3e33fdac0a 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,4 +1,7 @@ +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:mocktail/mocktail.dart'; class MockStoreRepository extends Mock implements IStoreRepository {} + +class MockLogRepository extends Mock implements ILogRepository {} diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 464dafc82b..e37b5ec7bc 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,15 +1,16 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; -import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -70,7 +71,10 @@ void main() { db.writeTxnSync(() => db.clearSync()); await StoreService.init(storeRepository: IsarStoreRepository(db)); await Store.put(StoreKey.currentUser, owner); - ImmichLogger(); + await LogService.init( + logRepo: IsarLogRepository(db), + storeRepo: IsarStoreRepository(db), + ); }); final List initialAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index 35ab1fb0aa..825d77190b 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -11,8 +13,8 @@ import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:isar/isar.dart'; import 'package:mocktail/mocktail.dart'; @@ -88,4 +90,36 @@ abstract final class TestUtils { WidgetController.hitTestWarningShouldBeFatal = true; HttpOverrides.global = MockHttpOverrides(); } + + // Workaround till the following issue is resolved + // https://github.com/dart-lang/test/issues/2307 + static T fakeAsync( + Future Function(FakeAsync _) callback, { + DateTime? initialTime, + }) { + late final T result; + Object? error; + StackTrace? stack; + FakeAsync(initialTime: initialTime).run((FakeAsync async) { + bool shouldPump = true; + unawaited( + callback(async).then( + (value) => result = value, + onError: (e, s) { + error = e; + stack = s; + }, + ).whenComplete(() => shouldPump = false), + ); + + while (shouldPump) { + async.flushMicrotasks(); + } + }); + + if (error != null) { + Error.throwWithStackTrace(error!, stack!); + } + return result; + } }