Files
plezy/lib/services/device_performance.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

151 lines
6.0 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
/// User override for the visual-effects tier (stored by SettingsService).
enum VisualEffectsSetting { auto, full, reduced }
/// Detects whether the device is too weak for the full visual-effects budget
/// and exposes a single sync gate ([isReduced]) the effect chokepoints check.
///
/// The reduced tier auto-triggers only on low-end Android hardware: a 32-bit
/// process (cheap TV boxes/sticks run 32-bit userspace), the system low-RAM
/// flag, or ≤ ~2.2 GiB total memory. All other platforms are always full
/// unless the user forces "reduced" via the setting.
class DevicePerformance {
DevicePerformance._();
static DevicePerformance? _instance;
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
/// reservations), below 3 GB Shield-class devices (~2.8 GiB).
static const int _lowMemThresholdBytes = 2252 << 20;
bool _autoReduced = false;
VisualEffectsSetting _override = VisualEffectsSetting.auto;
// Raw signals retained for the startup log line.
bool? _is64Bit;
bool? _isLowRam;
int? _totalMemBytes;
/// Get the singleton, detecting hardware signals on first call.
/// [override] is the persisted SettingsService.visualEffects value.
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
if (_instance == null) {
_instance = DevicePerformance._();
_instance!._override = override;
await _instance!._detect();
}
return _instance!;
}
Future<void> _detect() async {
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
try {
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
if (result == null) return;
_is64Bit = result['is64Bit'] == true;
_isLowRam = result['isLowRamDevice'] == true;
_totalMemBytes = (result['totalMemBytes'] as num?)?.toInt();
_autoReduced =
_is64Bit == false ||
_isLowRam == true ||
(_totalMemBytes != null && _totalMemBytes! <= _lowMemThresholdBytes);
} on MissingPluginException {
// Stale native build — stay on the full tier.
} on PlatformException {
// Signal query failed — stay on the full tier.
}
}
/// 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
/// boxes lagging a GL subtitle overlay — where a user's effects preference is
/// irrelevant. Safe before init (returns false). See [isReduced] for the
/// effects-tier gate that the override can force.
static bool get isLowEndHardware => _instance?._autoReduced ?? false;
/// Primary gate for effect chokepoints. Safe before init (full tier).
static bool get isReduced {
final instance = _instance;
if (instance == null) return false;
return switch (instance._override) {
VisualEffectsSetting.auto => instance._autoReduced,
VisualEffectsSetting.full => false,
VisualEffectsSetting.reduced => true,
};
}
/// [full] on the full tier, [Duration.zero] on the reduced tier.
static Duration reducedDuration(Duration full) => isReduced ? Duration.zero : full;
/// Update the user override from the settings screen and re-apply the
/// budgets that were computed at boot.
static void setOverrideSync(VisualEffectsSetting value) {
_instance?._override = value;
applyImageCacheBudget();
}
/// Flutter image-cache budget per platform/tier — kept modest to leave
/// headroom for Skia decode buffers.
static void applyImageCacheBudget() {
final cache = PaintingBinding.instance.imageCache;
if (PlatformDetector.isDesktopOS()) {
cache.maximumSize = 1000;
cache.maximumSizeBytes = 150 << 20; // 150MB
} else if (isReduced) {
cache.maximumSize = 400;
cache.maximumSizeBytes = 48 << 20; // 48MB
} else if (PlatformDetector.isTV()) {
// TV boxes share limited RAM with 4K video decode buffers.
cache.maximumSize = 500;
cache.maximumSizeBytes = 64 << 20; // 64MB
} else {
cache.maximumSize = 800;
cache.maximumSizeBytes = 100 << 20; // 100MB
}
}
/// 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';
final signals = <String>[
if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'),
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(', ')})';
}
@visibleForTesting
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
if (autoReduced == null && override == null) {
_instance = null;
return;
}
_instance ??= DevicePerformance._();
if (autoReduced != null) _instance!._autoReduced = autoReduced;
if (override != null) _instance!._override = override;
}
}