diff --git a/lib/main.dart b/lib/main.dart index b6204c5f..7b87ab14 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -224,6 +224,11 @@ Future _bootstrapApp() async { 'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer' ' [effects: ${DevicePerformance.describeSync()}]', ); + if (Platform.isAndroid) { + // Baseline for the RSS watchdog thresholds and a sanity anchor against + // `adb shell dumpsys meminfo` when tuning them. + appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB'); + } await DownloadStorageService.instance.initialize(settings); @@ -461,24 +466,20 @@ class _MainAppState extends State with WidgetsBindingObserver { /// Last time server health probes ran from a resume event (cooldown for desktop) DateTime _lastResumeProbe = DateTime(0); - /// Periodic memory check timer for desktop platforms + /// Periodic RSS watchdog timer (desktop + Android). Timer? _memoryCheckTimer; + /// Last watchdog eviction, for the cooldown; RSS at that moment so a + /// still-climbing RSS can re-evict inside the cooldown window. + DateTime _lastRssEviction = DateTime(0); + int _lastEvictionRss = 0; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - if (PlatformDetector.isDesktopOS()) { - _memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) { - final rss = ProcessInfo.currentRss; - if (rss > 1536 * 1024 * 1024) { - // 1.5GB - appLogger.w('RSS high ($rss bytes), evicting image caches'); - _evictImageCaches(); - } - }); - } + _startRssWatchdog(); _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); @@ -556,6 +557,48 @@ class _MainAppState extends State with WidgetsBindingObserver { _evictImageCaches(); } + /// RSS-based image-cache eviction. Desktop keeps its fixed 1.5GB bar; + /// Android scales to the device because LMK on a 2GB TV box kills well + /// below any fixed desktop threshold — and Android trim callbacks + /// ([didHaveMemoryPressure]) are best-effort, LMK can kill without ever + /// delivering one (#1349). + void _startRssWatchdog() { + final int threshold; + final Duration period; + if (PlatformDetector.isDesktopOS()) { + threshold = 1536 << 20; // 1.5GB + period = const Duration(seconds: 30); + } else if (Platform.isAndroid) { + final totalMem = DevicePerformance.totalMemBytes; + threshold = totalMem != null ? (totalMem * 0.45).round().clamp(512 << 20, 1536 << 20) : 1 << 30; + // Decode bursts can spike RSS in seconds on low-end boxes; the read + // itself is an in-process syscall, cheap enough for a short period. + period = DevicePerformance.isLowEndHardware ? const Duration(seconds: 15) : const Duration(seconds: 30); + } else { + return; // iOS/tvOS: jetsam pressure arrives via didHaveMemoryPressure. + } + + _memoryCheckTimer = Timer.periodic(period, (_) { + final rss = ProcessInfo.currentRss; + if (rss <= threshold) return; + final cache = PaintingBinding.instance.imageCache; + // Floor + cooldown: clearing an already-small cache buys nothing, and + // refetch churn is its own memory-spike and jank source. Inside the + // cooldown, re-evict only if RSS kept climbing past the last eviction. + if (cache.currentSizeBytes < (8 << 20)) return; + final now = DateTime.now(); + final inCooldown = now.difference(_lastRssEviction) < const Duration(seconds: 60); + if (inCooldown && rss <= _lastEvictionRss) return; + _lastRssEviction = now; + _lastEvictionRss = rss; + appLogger.w( + 'RSS high (${rss >> 20}MB > ${threshold >> 20}MB), evicting image caches ' + '(cache ${cache.currentSizeBytes >> 20}MB/${cache.currentSize} images, ${cache.liveImageCount} live)', + ); + _evictImageCaches(); + }); + } + void _evictImageCaches() { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); @@ -671,6 +714,15 @@ class _MainAppState extends State with WidgetsBindingObserver { // 1GB _evictImageCaches(); } + } else if (Platform.isAndroid) { + // A backgrounded app is LMK's first candidate; shed the image + // caches at a lower bar than the foreground watchdog to survive + // the HOME press on low-RAM boxes. + final totalMem = DevicePerformance.totalMemBytes; + final bar = totalMem != null ? (totalMem * 0.35).round() : 768 << 20; + if (ProcessInfo.currentRss > bar) { + _evictImageCaches(); + } } case AppLifecycleState.inactive: case AppLifecycleState.hidden: diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index e85a49af..43c4d8f1 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -16,6 +16,7 @@ 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'; @@ -58,6 +59,15 @@ class _LogsScreenState extends State with MountedSetStateMixin { 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('getRenderer') ?? 'unknown'; + } catch (_) { + renderer = 'unknown'; + } + buffer.writeln('Renderer: $renderer'); } else if (Platform.isIOS) { final info = await deviceInfo.iosInfo; buffer.writeln('iOS ${info.systemVersion}'); @@ -71,6 +81,8 @@ class _LogsScreenState extends State with MountedSetStateMixin { buffer.writeln('Linux ${info.versionId ?? info.id}'); } + buffer.writeln('Effects: ${DevicePerformance.describeSync()}'); + setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight()); } diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index f30a8ec5..e0233411 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -64,6 +64,10 @@ class DevicePerformance { } } + /// Total device RAM as reported by the platform, or null off-Android / + /// before init. Used to scale memory-watchdog thresholds to the device. + static int? get totalMemBytes => _instance?._totalMemBytes; + /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), /// independent of the visual-effects override. Use this for decisions tied to /// the hardware itself — e.g. the codec→display video pipeline on cheap TV @@ -113,18 +117,23 @@ class DevicePerformance { } } - /// One-line tier summary for the startup log, e.g. - /// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced)`. + /// One-line tier summary for the startup log and bug-report headers, e.g. + /// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced; hw: 64-bit, 2.8GiB)`. + /// + /// Raw signals are always included (even when the tier is forced) so an + /// uploaded log answers "did the reduced tier engage, and why / why not". static String describeSync() { final instance = _instance; if (instance == null) return 'unknown'; final tier = isReduced ? 'reduced' : 'full'; - if (instance._override != VisualEffectsSetting.auto) return '$tier (forced)'; final signals = [ if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'), - if (instance._isLowRam == true) 'lowRam', + if (instance._isLowRam != null) 'lowRam:${instance._isLowRam}', if (instance._totalMemBytes != null) '${(instance._totalMemBytes! / (1024 * 1024 * 1024)).toStringAsFixed(1)}GiB', ]; + if (instance._override != VisualEffectsSetting.auto) { + return signals.isEmpty ? '$tier (forced)' : '$tier (forced; hw: ${signals.join(', ')})'; + } return signals.isEmpty ? tier : '$tier (auto: ${signals.join(', ')})'; }