diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 6fdae4cf3..bbe871ade 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -45,6 +45,9 @@ class _LogsScreenState extends State with MountedSetStateMixin { List _logs = []; String _deviceInfo = ''; final ScrollController _scrollController = ScrollController(); + // skipTraversal: selection is pointer-driven; the region must not become a + // D-pad/Tab stop between the action bar and the scrollable body. + final FocusNode _selectionFocusNode = FocusNode(skipTraversal: true, debugLabel: 'logs-selection'); MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient; @@ -109,6 +112,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { @override void dispose() { _scrollController.dispose(); + _selectionFocusNode.dispose(); super.dispose(); } @@ -178,8 +182,15 @@ class _LogsScreenState extends State with MountedSetStateMixin { : constrainLogUploadPayload(header: header, logs: logText, maxBytes: maxBytes); } + /// Android binder transactions are capped around 1 MiB and + /// `Clipboard.setData` crosses one; an oversized payload aborts with + /// TransactionTooLargeException. UTF-16 parceling can double the UTF-8 + /// size, so stay well below the limit. Trimming keeps the newest lines, + /// matching the upload path. + static const int _maxClipboardBytes = 256 * 1024; + void _copyAllLogs() { - Clipboard.setData(ClipboardData(text: _formatAllLogs())); + Clipboard.setData(ClipboardData(text: _formatAllLogs(maxBytes: _maxClipboardBytes))); showSuccessSnackBar(context, t.messages.logsCopied); } @@ -264,57 +275,45 @@ class _LogsScreenState extends State with MountedSetStateMixin { ); } - List _buildLogSpans() { - final spans = []; - if (_deviceInfo.isNotEmpty) { - spans.add( + TextStyle? _logTextStyle(ThemeData theme) => + theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12, height: 1.5); + + List _buildDeviceInfoSpans() { + return [ + TextSpan( + text: '$_deviceInfo\n', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)), + ), + TextSpan( + text: '---', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)), + ), + ]; + } + + List _buildEntrySpans(LogEntry log) { + final color = _getLevelColor(log.level); + return [ + TextSpan( + text: '[${_formatTime(log.timestamp)}] ', + style: TextStyle(color: color.withValues(alpha: 0.6)), + ), + TextSpan( + text: '[${log.level.name.toUpperCase()}] ', + style: TextStyle(color: color, fontWeight: .bold), + ), + TextSpan(text: log.message), + if (log.error != null) TextSpan( - text: '$_deviceInfo\n', - style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)), + text: '\n Error: ${log.error}', + style: TextStyle(color: color), ), - ); - spans.add( + if (log.stackTrace != null) TextSpan( - text: '---\n', - style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)), + text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)), ), - ); - } - for (var i = 0; i < _logs.length; i++) { - if (i > 0) spans.add(const TextSpan(text: '\n')); - final log = _logs[i]; - final color = _getLevelColor(log.level); - spans.add( - TextSpan( - text: '[${_formatTime(log.timestamp)}] ', - style: TextStyle(color: color.withValues(alpha: 0.6)), - ), - ); - spans.add( - TextSpan( - text: '[${log.level.name.toUpperCase()}] ', - style: TextStyle(color: color, fontWeight: .bold), - ), - ); - spans.add(TextSpan(text: log.message)); - if (log.error != null) { - spans.add( - TextSpan( - text: '\n Error: ${log.error}', - style: TextStyle(color: color), - ), - ); - } - if (log.stackTrace != null) { - spans.add( - TextSpan( - text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}', - style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)), - ), - ); - } - } - return spans; + ]; } /// Banner for a startup failure recorded by an earlier launch. @@ -387,62 +386,74 @@ class _LogsScreenState extends State with MountedSetStateMixin { child: IosStatusBarTapScrollToTop( controller: _scrollController, child: Scaffold( - body: CustomScrollView( - primary: true, - slivers: [ - CustomAppBar( - title: Text(t.screens.logs), - pinned: true, - actions: [ - FocusableActionBar( - actions: [ - FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs), - FocusableAction( - icon: Symbols.upload_rounded, - tooltip: t.logs.uploadLogs, - onPressed: _hasDiagnostics ? _uploadLogs : null, - ), - FocusableAction( - icon: Symbols.content_copy_rounded, - tooltip: t.logs.copyLogs, - onPressed: _hasDiagnostics ? _copyAllLogs : null, - ), - FocusableAction( - icon: Symbols.delete_outline_rounded, - tooltip: t.logs.clearLogs, - onPressed: _hasDiagnostics ? _clearLogs : null, - ), - ], - ), - ], - ), - // A launch that failed the startup gate leaves nothing in the - // in-memory buffer — that process is gone. Show its record - // here, where the user can actually act on it (#1732). - ?_buildPreviousFailureBanner(theme), - if (_logs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) - else ...[ - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: SelectableText.rich( - TextSpan( - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - fontSize: 12, - height: 1.5, + body: SelectionArea( + focusNode: _selectionFocusNode, + child: CustomScrollView( + primary: true, + slivers: [ + CustomAppBar( + // Chrome is not log content; keep it out of drag-selection. + title: SelectionContainer.disabled(child: Text(t.screens.logs)), + pinned: true, + actions: [ + FocusableActionBar( + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, + tooltip: t.common.refresh, + onPressed: _loadLogs, ), - children: _buildLogSpans(), + FocusableAction( + icon: Symbols.upload_rounded, + tooltip: t.logs.uploadLogs, + onPressed: _hasDiagnostics ? _uploadLogs : null, + ), + FocusableAction( + icon: Symbols.content_copy_rounded, + tooltip: t.logs.copyLogs, + onPressed: _hasDiagnostics ? _copyAllLogs : null, + ), + FocusableAction( + icon: Symbols.delete_outline_rounded, + tooltip: t.logs.clearLogs, + onPressed: _hasDiagnostics ? _clearLogs : null, + ), + ], + ), + ], + ), + // A launch that failed the startup gate leaves nothing in the + // in-memory buffer — that process is gone. Show its record + // here, where the user can actually act on it (#1732). + ?_buildPreviousFailureBanner(theme), + if (_logs.isEmpty) + SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) + else ...[ + if (_deviceInfo.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + sliver: SliverToBoxAdapter( + child: Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans())), ), ), + SliverPadding( + padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12), + // One widget per entry so only the visible slice is laid + // out. The buffer holds up to 5 MiB of text; as a single + // paragraph that was a multi-second frame and hundreds of + // MB of glyph data — an OOM kill on phones and TVs. + sliver: SliverList.builder( + itemCount: _logs.length, + itemBuilder: (context, index) => + Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index]))), + ), ), - ), - // Only the log body needs it: the empty state already fills - // the viewport, so a trailing inset would just add slack. - const SliverSystemBottomInset(), + // Only the log body needs it: the empty state already fills + // the viewport, so a trailing inset would just add slack. + const SliverSystemBottomInset(), + ], ], - ], + ), ), ), ), diff --git a/test/screens/settings/logs_screen_test.dart b/test/screens/settings/logs_screen_test.dart index c11ad0fd9..fb108cc6c 100644 --- a/test/screens/settings/logs_screen_test.dart +++ b/test/screens/settings/logs_screen_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:logger/logger.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; @@ -226,4 +227,113 @@ void main() { } }); }); + + group('log body rendering', () { + late DeviceInfoPlugin deviceInfo; + + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Plezy', + packageName: 'com.plezy.test', + version: '1.2.3', + buildNumber: '45', + buildSignature: '', + ); + deviceInfo = DeviceInfoPlugin.setMockInitialValues( + linuxDeviceInfo: LinuxDeviceInfo( + name: 'Test Linux', + id: 'test-linux', + prettyName: 'Test Linux', + machineId: 'test-machine', + ), + ); + const deviceInfoChannel = MethodChannel('dev.fluttercommunity.plus/device_info'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(deviceInfoChannel, (call) async { + return { + 'computerName': 'test-mac', + 'hostName': 'test-mac.local', + 'arch': 'arm64', + 'model': 'Mac15,3', + 'modelName': 'Mac', + 'kernelVersion': 'test', + 'osRelease': '15.0', + 'majorVersion': 15, + 'minorVersion': 0, + 'patchVersion': 0, + 'activeCPUs': 8, + 'memorySize': 16 * 1024 * 1024 * 1024, + 'cpuFrequency': 0, + 'systemGUID': 'test-guid', + }; + }); + addTearDown(() => messenger.setMockMethodCallHandler(deviceInfoChannel, null)); + }); + + // Stores entries without echoing thousands of lines to the test console. + void seedLogs(Iterable messages) { + final printer = MemoryAwareLogPrinter(SimplePrinter()); + for (final message in messages) { + printer.log(LogEvent(Level.debug, message)); + } + } + + Future pumpLogs(WidgetTester tester) async { + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: MaterialApp(home: LogsScreen(deviceInfoPlugin: deviceInfo)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('lays out only the visible slice of a large buffer', (tester) async { + // Rendering the whole buffer as one paragraph froze the frame for tens + // of seconds and OOM-killed low-memory devices; offscreen entries must + // never be materialized. + seedLogs(['oldest-entry-marker', for (var i = 1; i < 1999; i++) 'entry-$i payload', 'newest-entry-marker']); + + await pumpLogs(tester); + + // Newest entry renders at the top; the oldest is offscreen and unbuilt. + expect(find.textContaining('newest-entry-marker', findRichText: true), findsOneWidget); + expect(find.textContaining('oldest-entry-marker', findRichText: true), findsNothing); + + // The tail is still reachable by scrolling. + final position = tester.state(find.byType(Scrollable).first).position; + for (var i = 0; i < 10 && position.pixels < position.maxScrollExtent; i++) { + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + } + expect(find.textContaining('oldest-entry-marker', findRichText: true), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('caps the copied payload below the binder limit and keeps the newest lines', (tester) async { + String? clipboardText; + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = (call.arguments as Map)['text'] as String?; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(SystemChannels.platform, null)); + + final filler = 'x' * 1024; + seedLogs(['oldest-copy-marker', for (var i = 1; i < 599; i++) 'copy-entry-$i $filler', 'newest-copy-marker']); + + await pumpLogs(tester); + await tester.tap(find.byTooltip(t.logs.copyLogs)); + await tester.pump(); + + expect(clipboardText, isNotNull); + expect(utf8.encode(clipboardText!).length, lessThanOrEqualTo(256 * 1024)); + expect(clipboardText, contains('newest-copy-marker')); + expect(clipboardText, isNot(contains('oldest-copy-marker'))); + expect(tester.takeException(), isNull); + }); + }); }