Files
plezy/lib/screens/settings/logs_screen.dart
T
edde746 aaf27a7f9e fix(android): extend the RSS eviction watchdog beyond desktop and log tier signals
Android trim callbacks are best-effort — LMK can kill without ever
delivering one — yet the RSS watchdog only ran on desktop with a fixed
1.5GB bar no 2GB TV box ever reaches before dying. The watchdog now
runs on Android with a threshold scaled to device RAM, an eviction
floor and cooldown against refetch churn, and a lower backgrounded bar
since a paused app is LMK's first candidate.

The logs-screen header and startup line now record the renderer, the
effects tier with its raw hardware signals, and the boot RSS, so
uploaded reports answer whether the reduced tier engaged.

Ref #1349
2026-07-05 09:10:36 +02:00

363 lines
12 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/mounted_set_state_mixin.dart';
import '../../utils/dialogs.dart';
import '../../main.dart' show gitCommit;
import '../../services/device_performance.dart';
import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@override
State<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
List<LogEntry> _logs = [];
String _deviceInfo = '';
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_logs = MemoryLogOutput.getLogs();
_loadDeviceInfo();
}
Future<void> _loadDeviceInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
final deviceInfo = DeviceInfoPlugin();
final buffer = StringBuffer();
final commitSuffix = gitCommit.isNotEmpty ? ' ${gitCommit.substring(0, 7)}' : '';
buffer.writeln('${t.app.title} v${packageInfo.version} (${packageInfo.buildNumber})$commitSuffix');
if (Platform.isAndroid) {
final info = await deviceInfo.androidInfo;
buffer.writeln('Android ${info.version.release} (API ${info.version.sdkInt})');
buffer.writeln('${info.manufacturer} ${info.model}');
if (TvDetectionService.isTVSync()) {
final reasons = TvDetectionService.tvDetectionReasonsSync();
final suffix = reasons.isEmpty ? '' : ' (${reasons.join(', ')})';
buffer.writeln('TV mode: yes$suffix');
}
// Renderer + effects tier turn "did the reduced tier engage on this
// device" into something answerable from any uploaded log (#1349).
String renderer;
try {
renderer = await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer') ?? 'unknown';
} catch (_) {
renderer = 'unknown';
}
buffer.writeln('Renderer: $renderer');
} else if (Platform.isIOS) {
final info = await deviceInfo.iosInfo;
buffer.writeln('iOS ${info.systemVersion}');
buffer.writeln(info.utsname.machine);
} else if (Platform.isMacOS) {
final info = await deviceInfo.macOsInfo;
buffer.writeln('macOS ${info.osRelease}');
buffer.writeln(info.model);
} else if (Platform.isLinux) {
final info = await deviceInfo.linuxInfo;
buffer.writeln('Linux ${info.versionId ?? info.id}');
}
buffer.writeln('Effects: ${DevicePerformance.describeSync()}');
setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight());
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _loadLogs() {
setState(() {
_logs = MemoryLogOutput.getLogs();
});
}
String _formatTime(DateTime time) {
final hour = padNumber(time.hour, 2);
final minute = padNumber(time.minute, 2);
final second = padNumber(time.second, 2);
final millisecond = padNumber(time.millisecond, 3);
return '$hour:$minute:$second.$millisecond';
}
void _clearLogs() {
setState(() {
MemoryLogOutput.clearLogs();
_logs = [];
});
showSuccessSnackBar(context, t.messages.logsCleared);
}
String _formatAllLogs() {
final buffer = StringBuffer();
if (_deviceInfo.isNotEmpty) {
buffer.writeln(_deviceInfo);
buffer.writeln('---');
}
bool isFirst = true;
for (final log in _logs.reversed) {
if (!isFirst) {
buffer.write('\n');
}
isFirst = false;
buffer.write('[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}');
if (log.error != null) {
buffer.write('\nError: ${log.error}');
}
if (log.stackTrace != null) {
buffer.write('\nStack trace:\n${log.stackTrace}');
}
}
return buffer.toString();
}
void _copyAllLogs() {
Clipboard.setData(ClipboardData(text: _formatAllLogs()));
showSuccessSnackBar(context, t.messages.logsCopied);
}
Future<void> _uploadLogs() async {
final logText = _formatAllLogs();
showLoadingDialog(context);
try {
final response = await httpClient.post(
'https://ice.plezy.app/logs',
body: logText,
headers: {'Content-Type': 'text/plain'},
);
if (!mounted) return;
Navigator.of(context).pop(); // dismiss loading
final data = response.data is String ? jsonDecode(response.data) : response.data;
final id = (data as Map<String, dynamic>)['id'] as String;
unawaited(
showScopedDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.messages.logsUploaded),
content: Row(
children: [
Text('${t.messages.logId}: '),
SelectableText(
id,
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: id));
showSuccessSnackBar(context, t.messages.logsCopied);
},
),
],
),
actions: [
FocusableButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
),
],
),
),
);
} catch (_) {
if (!mounted) return;
Navigator.of(context).pop(); // dismiss loading
showErrorSnackBar(context, t.messages.logsUploadFailed);
}
}
Color _getLevelColor(Level level) {
switch (level) {
case Level.error:
case Level.fatal:
return Colors.red;
case Level.warning:
return Colors.orange;
case Level.info:
return Colors.blue;
case Level.debug:
case Level.trace:
return Colors.grey;
default:
return Colors.grey;
}
}
void _scroll(double delta) {
final pos = _scrollController.position;
_scrollController.animateTo(
(pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent),
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
List<TextSpan> _buildLogSpans() {
final spans = <TextSpan>[];
if (_deviceInfo.isNotEmpty) {
spans.add(
TextSpan(
text: '$_deviceInfo\n',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
),
);
spans.add(
TextSpan(
text: '---\n',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
),
);
}
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;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyNavigation(context, event);
if (backResult != KeyEventResult.ignored) return backResult;
if (event is KeyDownEvent || event is KeyRepeatEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_scroll(80);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_scroll(-80);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: PrimaryScrollController(
controller: _scrollController,
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: _logs.isNotEmpty ? _uploadLogs : null,
),
FocusableAction(
icon: Symbols.content_copy_rounded,
tooltip: t.logs.copyLogs,
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
),
FocusableAction(
icon: Symbols.delete_outline_rounded,
tooltip: t.logs.clearLogs,
onPressed: _logs.isNotEmpty ? _clearLogs : null,
),
],
),
],
),
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,
),
children: _buildLogSpans(),
),
),
),
),
],
),
),
),
),
);
}
}