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:
@@ -2025,6 +2025,7 @@
|
|||||||
"share_everyone": "Share everyone",
|
"share_everyone": "Share everyone",
|
||||||
"share_everyone_description": "Give {user} access to all of your people.",
|
"share_everyone_description": "Give {user} access to all of your people.",
|
||||||
"share_link": "Share Link",
|
"share_link": "Share Link",
|
||||||
|
"share_logs": "Share Logs",
|
||||||
"share_original": "Use original (large)",
|
"share_original": "Use original (large)",
|
||||||
"share_preview": "Use preview (small)",
|
"share_preview": "Use preview (small)",
|
||||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ void main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
Logger('main').severe('Failed to start', error, stack);
|
||||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
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/view_intent/view_intent_handler.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.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/color_scheme.dart';
|
||||||
import 'package:immich_mobile/theme/theme_data.dart';
|
import 'package:immich_mobile/theme/theme_data.dart';
|
||||||
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
import 'package:immich_mobile/utils/fallback_localizations.dart';
|
||||||
@@ -78,9 +79,12 @@ class BootstrapErrorWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SafeArea(
|
SafeArea(
|
||||||
top: false,
|
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 {
|
class _BottomPanel extends StatefulWidget {
|
||||||
const _BottomPanel();
|
final String startupError;
|
||||||
|
|
||||||
|
const _BottomPanel({required this.startupError});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_BottomPanel> createState() => _BottomPanelState();
|
State<_BottomPanel> createState() => _BottomPanelState();
|
||||||
@@ -157,8 +163,8 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_ActionLink(
|
_ActionLink(
|
||||||
icon: Icons.chat_bubble_outline,
|
icon: Icons.chat_bubble_outline,
|
||||||
@@ -173,6 +179,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_ActionLink(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: context.t.share_logs,
|
||||||
|
onTap: () => ImmichLogger.shareLogs(context, startupError: widget.startupError),
|
||||||
|
),
|
||||||
if (!_cleared)
|
if (!_cleared)
|
||||||
_ActionLink(
|
_ActionLink(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
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/domain/services/log.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:share_plus/share_plus.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
|
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
|
||||||
/// and generate a csv file.
|
/// and generate a csv file.
|
||||||
abstract final class ImmichLogger {
|
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 tempDir = await getTemporaryDirectory();
|
||||||
final dateTime = DateTime.now().toIso8601String();
|
final dateTime = DateTime.now().toIso8601String();
|
||||||
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
|
final filePath = '${tempDir.path}/Immich_log_$dateTime.log';
|
||||||
@@ -23,7 +46,7 @@ abstract final class ImmichLogger {
|
|||||||
final io = logFile.openWrite();
|
final io = logFile.openWrite();
|
||||||
try {
|
try {
|
||||||
// Write messages
|
// Write messages
|
||||||
for (final m in await LogService.I.getMessages()) {
|
for (final m in await _read(startupError)) {
|
||||||
final created = m.createdAt;
|
final created = m.createdAt;
|
||||||
final level = m.level.name.padRight(8);
|
final level = m.level.name.padRight(8);
|
||||||
final logger = (m.logger ?? "<UNKNOWN_LOGGER>").padRight(20);
|
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 |'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user