diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 77254873..e75aea3a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3043,6 +3043,12 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "releaseMemory" -> { + withContext(Dispatchers.IO) { + Gobackend.releaseMemory() + } + result.success(null) + } "getLogCount" -> { val count = withContext(Dispatchers.IO) { Gobackend.getLogCount() diff --git a/go_backend/exports.go b/go_backend/exports.go index 4ea78fdf..12b13832 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2,9 +2,19 @@ package gobackend import ( "encoding/json" + "runtime/debug" "strings" ) +// ReleaseMemory drops idle pooled extension runtimes, forces a GC, and +// returns freed heap to the OS. Called from the app on OS memory pressure and +// when backgrounded, so the Go side's RSS doesn't sit at its high-water mark +// after large downloads/tag writes. +func ReleaseMemory() { + drainAllIsolatedRuntimePools() + debug.FreeOSMemory() +} + func CheckAvailability(spotifyID, isrc string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) diff --git a/lib/main.dart b/lib/main.dart index b47da80c..36407f2a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; @@ -157,9 +158,25 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> ref.read(downloadQueueProvider.notifier).flushQueuePersistence(), ); } + // Backgrounded: return the Go heap's high-water mark to the OS so the + // process is a smaller kill target. + unawaited(PlatformBridge.releaseNativeMemory()); } } + @override + void didHaveMemoryPressure() { + // OS memory pressure: drop decoded bitmaps (disk caches stay intact) and + // have the Go side release freed heap back to the OS. + final imageCache = PaintingBinding.instance.imageCache; + imageCache.clear(); + imageCache.clearLiveImages(); + if (CoverCacheManager.isInitialized) { + CoverCacheManager.instance.store.emptyMemoryCache(); + } + unawaited(PlatformBridge.releaseNativeMemory()); + } + void _initializeDeferredProviders() { _downloadHistoryWarmupTimer = _scheduleProviderWarmup( const Duration(milliseconds: 400), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 30ae2212..09a11432 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1202,6 +1202,14 @@ class PlatformBridge { await _channel.invokeMethod('clearLogs'); } + /// Ask the Go backend to GC and return freed heap to the OS. Best-effort: + /// safe to call on memory pressure or when the app is backgrounded. + static Future releaseNativeMemory() async { + try { + await _channel.invokeMethod('releaseMemory'); + } catch (_) {} + } + static Future getGoLogCount() async { final result = await _channel.invokeMethod('getLogCount'); return result as int;