diff --git a/i18n/en.json b/i18n/en.json index 9077161f1..fd6c3c501 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2025,6 +2025,7 @@ "share_everyone": "Share everyone", "share_everyone_description": "Give {user} access to all of your people.", "share_link": "Share Link", + "share_logs": "Share Logs", "share_original": "Use original (large)", "share_preview": "Use preview (small)", "share_quality_body": "Press and hold the share button to choose the image quality before you share.", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index aa6be0637..b8af92e6b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -74,6 +74,7 @@ void main() async { ), ); } catch (error, stack) { + Logger('main').severe('Failed to start', error, stack); runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString())); } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 57e07fbc1..69835598b 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -22,6 +22,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/fallback_localizations.dart'; @@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget { ), ), const Divider(height: 1), - const SafeArea( + SafeArea( top: false, - child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: _BottomPanel(startupError: '$error\n\n$stack'), + ), ), ], ), @@ -93,7 +97,9 @@ class BootstrapErrorWidget extends StatelessWidget { } class _BottomPanel extends StatefulWidget { - const _BottomPanel(); + final String startupError; + + const _BottomPanel({required this.startupError}); @override State<_BottomPanel> createState() => _BottomPanelState(); @@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> { textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Wrap( + alignment: WrapAlignment.center, children: [ _ActionLink( icon: Icons.chat_bubble_outline, @@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> { mode: LaunchMode.externalApplication, ), ), + _ActionLink( + icon: Icons.share_outlined, + label: context.t.share_logs, + onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError), + ), if (!_cleared) _ActionLink( icon: Icons.delete_outline, diff --git a/mobile/lib/services/immich_logger.service.dart b/mobile/lib/services/immich_logger.service.dart index 67822c69e..f74621ee3 100644 --- a/mobile/lib/services/immich_logger.service.dart +++ b/mobile/lib/services/immich_logger.service.dart @@ -2,7 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/data/db/logger/database.dart'; +import 'package:immich_mobile/data/db/main/database.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -15,7 +19,26 @@ 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. abstract final class ImmichLogger { - static Future shareLogs(BuildContext context) async { + // the startup error screen can show before the log service starts, and then the startup error was not logged + static Future> _read(String? startupError) async { + try { + return await LogService.I.getMessages(); + } on LoggerUnInitializedException { + final logs = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs')); + try { + return [ + // if the log service had started, main would have logged this already + if (startupError != null) + LogMessage(message: startupError, level: LogLevel.severe, createdAt: DateTime.now()), + ...await LogRepository(logs).getAll(), + ]; + } finally { + await logs.close(); + } + } + } + + static Future shareLogs(BuildContext context, {String? startupError}) async { final tempDir = await getTemporaryDirectory(); final dateTime = DateTime.now().toIso8601String(); final filePath = '${tempDir.path}/Immich_log_$dateTime.log'; @@ -23,7 +46,7 @@ abstract final class ImmichLogger { final io = logFile.openWrite(); try { // Write messages - for (final m in await LogService.I.getMessages()) { + for (final m in await _read(startupError)) { final created = m.createdAt; final level = m.level.name.padRight(8); final logger = (m.logger ?? "").padRight(20); diff --git a/mobile/test/services/immich_logger.service_test.dart b/mobile/test/services/immich_logger.service_test.dart new file mode 100644 index 000000000..0ae4c2e46 --- /dev/null +++ b/mobile/test/services/immich_logger.service_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/data/db/logger/database.dart'; +import 'package:immich_mobile/data/db/main/database.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/services/immich_logger.service.dart'; + +void main() { + testWidgets('Shares the startup error and the stored logs before the log service starts', (tester) async { + final dir = Directory.systemTemp.createTempSync('immich-logs'); + addTearDown(() => dir.delete(recursive: true)); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (_) async => dir.path, + ); + late String shared; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/share'), + (call) async { + shared = File(((call.arguments as Map)['paths'] as List).single as String).readAsStringSync(); + return 'success'; + }, + ); + await tester.pumpWidget(const SizedBox()); + + await tester.runAsync(() async { + final logs = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs')); + await LogRepository(logs).insertAll([ + LogMessage( + message: 'Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api', + level: LogLevel.warning, + createdAt: DateTime(2026, 10, 15), + ), + ]); + await logs.close(); + + await ImmichLogger.shareLogs( + tester.element(find.byType(SizedBox)), + startupError: 'SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com', + ); + }); + + expect( + shared.split('\n').first, + endsWith('| SqliteException(2067): UNIQUE constraint failed, parameters: 14, someone@example.com |'), + ); + expect( + shared, + contains('| Failed to connect to old.example.net/203.0.113.9:443, uri=https://old.example.net/api |'), + ); + }); +}