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
363 lines
12 KiB
Dart
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(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|