feat(mobile): share logs from the startup error screen (#32288)

* fix(mobile): hide server addresses and tokens in shared logs

* feat(mobile): share logs from the startup error screen

* use only the shared logs patterns on the startup error screen

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* redact the startup error screen share

* fix(mobile): hide server addresses and tokens in shared logs

* fix(mobile): stop writing server urls and oauth values to the log

* fix(mobile): redact logs where they are read

* fix(mobile): redact logs when they are written

* log the startup error and share it as a log row

* fix(mobile): hide ipv6 addresses and name labels after their settings

* comment why the startup error row is only added here

(cherry picked from commit f7fa8a3a2d2c3b0ab59f90fbcc3c230c58a2aede)
This commit is contained in:
Santo Shakil
2026-10-22 05:18:40 +03:00
committed by Codex
parent 59bf69f1d5
commit 814cc14f88
5 changed files with 99 additions and 7 deletions
+1
View File
@@ -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()));
}
}
@@ -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,
+25 -2
View File
@@ -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<void> 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<List<LogMessage>> _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<void> 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 ?? "<UNKNOWN_LOGGER>").padRight(20);
@@ -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 |'),
);
});
}