Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .claude/rules/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,11 @@ void dispose() {
The project uses a 500 MB LRU disk cache for video content. Don't override this without profiling on low-end devices. Large caches cause storage pressure on older phones.

### Image Cache Tuning
Flutter's default `ImageCache` holds 100 images / 100 MB. For media-heavy feeds, monitor memory in DevTools. If OOM crashes occur on low-end devices, consider:
Startup sizes Flutter's decoded-image cache by device RAM in `mobile/lib/startup/image_cache_budget.dart`: RAM / 48, clamped to 96–256 MB, and 128 MB when RAM is unknown (web, desktop, or a failed channel call). The divisor and the bounds are `AppConstants.imageCache*`; change the budget through them. Another `imageCache.maximumSizeBytes` assignment anywhere else silently overrides it on every device. Flutter's own default (1000 entries, 100 MiB) applies only until startup sets it.

```dart
PaintingBinding.instance.imageCache.maximumSizeBytes =
50 * 1024 * 1024; // 50 MB
```
Clearing the cache frees nothing until a frame is drawn, because `ImageCache` releases each image it drops in a post-frame callback. Clear through `clearDecodedImageCache()` (`mobile/lib/utils/decoded_image_cache.dart`), which schedules that frame, rather than calling `imageCache.clear()` directly.

Only tune this with profiling data — don't guess.
Only tune the budget with profiling data — don't guess.

---

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package co.openvine.app

import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
Expand Down Expand Up @@ -49,6 +51,7 @@ class MainActivity : FlutterFragmentActivity() {
private const val NAV_TAG = "OpenVineNavigation"
private const val INSTALL_SOURCE_CHANNEL = "divine/install_source"
private const val INSTALL_SOURCE_TAG = "OpenVineInstallSource"
private const val DEVICE_MEMORY_CHANNEL = "divine/device_memory"
}

private var navigationChannel: MethodChannel? = null
Expand Down Expand Up @@ -85,6 +88,9 @@ class MainActivity : FlutterFragmentActivity() {
// AppUpdateRepository's download-URL resolution (#6296).
setupInstallSourceChannel(flutterEngine)

// Physical RAM, which sizes Flutter's decoded-image cache (#10301).
setupDeviceMemoryChannel(flutterEngine)

// Set up NIP-55 Android Signer plugin
nostrSignerPlugin = NostrSignerPlugin(this, flutterEngine)

Expand Down Expand Up @@ -200,6 +206,26 @@ class MainActivity : FlutterFragmentActivity() {
Log.d(INSTALL_SOURCE_TAG, "Install source channel registered")
}

private fun setupDeviceMemoryChannel(flutterEngine: FlutterEngine) {
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
DEVICE_MEMORY_CHANNEL,
).setMethodCallHandler { call, result ->
when (call.method) {
"getPhysicalMemoryBytes" -> {
val activityManager =
getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val info = ActivityManager.MemoryInfo()
activityManager?.getMemoryInfo(info)
// totalMem is 0 when the service is unavailable; Dart
// treats null as unknown and falls back to a fixed budget.
result.success(info.totalMem.takeIf { it > 0 })
}
else -> result.notImplemented()
}
}
}

override fun onDestroy() {
isActivityDestroyed = true
activityScope.cancel()
Expand Down
24 changes: 24 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ extension FlutterError: @retroactive Error {}
// Set up app-icon badge channel.
setupAppBadgeChannel(with: engineBridge)

// Physical RAM, which sizes Flutter's decoded-image cache (#10301).
setupDeviceMemoryChannel(with: engineBridge)

NSLog("✅ AppDelegate: Implicit Flutter engine initialized with UIScene lifecycle")
}

Expand Down Expand Up @@ -277,6 +280,27 @@ extension FlutterError: @retroactive Error {}
NSLog("✅ AppBadge: Platform channel registered")
}

/// Device-memory platform channel.
///
/// The Dart side reads the physical RAM once at startup to size Flutter's
/// decoded-image cache.
private func setupDeviceMemoryChannel(with engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "divine/device_memory",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)

channel.setMethodCallHandler { (call, result) in
switch call.method {
case "getPhysicalMemoryBytes":
result(Int(clamping: ProcessInfo.processInfo.physicalMemory))

default:
result(FlutterMethodNotImplemented)
}
}
}

private func setupZendeskChannel(with engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.openvine/zendesk_support",
Expand Down
7 changes: 2 additions & 5 deletions mobile/lib/app/divine_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import 'package:openvine/startup/app_composition_root.dart';
import 'package:openvine/startup/app_side_effects.dart';
import 'package:openvine/startup/divine_material_app.dart';
import 'package:openvine/startup/startup_splash_release_controller.dart';
import 'package:openvine/utils/decoded_image_cache.dart';
import 'package:openvine/utils/detached_future.dart';
import 'package:openvine/widgets/vine_cached_image.dart';
import 'package:package_info_plus/package_info_plus.dart';
Expand Down Expand Up @@ -190,11 +191,7 @@ class _DivineAppState extends ConsumerState<DivineApp>
// before our handler clears live images and sheds ingestion below.
_sampleMemory('memory_pressure');
},
clearImageCache: () {
PaintingBinding.instance.imageCache
..clear()
..clearLiveImages();
},
clearImageCache: () => clearDecodedImageCache(includeLiveImages: true),
shedIngestion: () {
ref.read(videoEventServiceProvider).eventRouter?.shedLowPriority();
},
Expand Down
24 changes: 21 additions & 3 deletions mobile/lib/constants/app_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,34 @@ class AppConstants {
// IMAGE CACHE CONFIGURATION
// ============================================================================

/// Byte budget for Flutter's in-memory [ImageCache] (decoded images).
/// Largest byte budget for Flutter's in-memory [ImageCache] (decoded
/// images).
///
/// Flutter's default is 100 MB, which on the thumbnail grids only holds a few
/// dozen decoded thumbnails — fast scrolling evicts already-loaded thumbnails,
/// so they flash back through their blurhash placeholder when scrolled into
/// view again. A larger budget keeps a much bigger working set resident so
/// scroll-back is an instant (synchronous) cache hit. Per-thumbnail memory is
/// bounded separately by decoding at display size, so this stays bounded.
/// scroll-back is an instant (synchronous) cache hit. Startup sizes the
/// budget by device RAM (`imageCacheBudgetBytes`), up to this value.
///
/// The budget counts decoded pixels, not what the GPU holds: Impeller keeps
/// each image as a texture with a mip chain, and every MB of cache measured
/// as about 2 MB of graphics memory on a Galaxy S26 and an iPhone 12 Pro
/// (#10301). This ceiling therefore costs about 500 MB once browsing fills it.
static const int imageCacheMaxBytes = 256 * 1024 * 1024;

/// Smallest RAM-sized [ImageCache] budget, for devices with 4 GB of RAM or
/// less: an estimated 11 grid screens of instant scroll-back (#10301).
static const int imageCacheMinBytes = 96 * 1024 * 1024;

/// [ImageCache] budget when the device's RAM cannot be read. It still
/// keeps 15–17 grid screens of instant scroll-back (#10301).
static const int imageCacheFallbackBytes = 128 * 1024 * 1024;

/// The RAM-sized [ImageCache] budget is physical RAM divided by this, so its
/// GPU cost (about twice the budget) stays near 4 % of RAM.
static const int imageCacheRamDivisor = 48;

// ============================================================================
// NETWORK CONFIGURATION
// ============================================================================
Expand Down
49 changes: 49 additions & 0 deletions mobile/lib/services/device_memory_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// ABOUTME: Reads the device's physical RAM through a native method channel.
// ABOUTME: Returns null where the platform does not report it.

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:unified_logger/unified_logger.dart';

/// Method-channel-backed reader for the device's physical memory.
///
/// Android reports `ActivityManager.MemoryInfo.totalMem`, iOS
/// `ProcessInfo.physicalMemory`. Both are a little below the installed RAM,
/// because the kernel and firmware keep part of it. Web and desktop builds
/// have no channel and report null.
class DeviceMemoryService {
const DeviceMemoryService({MethodChannel? channel})
: _channel = channel ?? const MethodChannel(_channelName);

static const _channelName = 'divine/device_memory';

final MethodChannel _channel;

/// Physical memory in bytes as the OS reports it, or null when unknown.
Future<int?> physicalMemoryBytes() async {
if (kIsWeb ||
!(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS)) {
return null;
}

try {
final bytes = await _channel.invokeMethod<int>('getPhysicalMemoryBytes');
return bytes != null && bytes > 0 ? bytes : null;
} on PlatformException catch (e) {
Log.warning(
'Device memory lookup failed: ${e.code}',
name: 'DeviceMemoryService',
category: LogCategory.system,
);
return null;
} on MissingPluginException {
Log.warning(
'Device memory channel missing',
name: 'DeviceMemoryService',
category: LogCategory.system,
);
return null;
}
}
}
21 changes: 14 additions & 7 deletions mobile/lib/startup/app_bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import 'package:openvine/bootstrap/editor_font_licenses.dart';
import 'package:openvine/bootstrap/font_licenses.dart';
import 'package:openvine/bootstrap/shorebird_licenses.dart';
import 'package:openvine/config/screenshot_mode.dart';
import 'package:openvine/constants/app_constants.dart';
import 'package:openvine/features/app/startup/startup_phase.dart';
import 'package:openvine/l10n/current_app_l10n.dart';
import 'package:openvine/models/environment_config.dart';
Expand All @@ -43,6 +42,7 @@ import 'package:openvine/services/crash_reporting_service.dart';
import 'package:openvine/services/database_corruption_service.dart';
import 'package:openvine/services/database_encryption_bootstrap.dart';
import 'package:openvine/services/database_recovery_store.dart';
import 'package:openvine/services/device_memory_service.dart';
import 'package:openvine/services/install_source_service.dart';
import 'package:openvine/services/locale_preference_service.dart';
import 'package:openvine/services/pro_video_editor_log_forwarder.dart';
Expand All @@ -55,6 +55,7 @@ import 'package:openvine/services/video_editor/video_render_watchdog.dart';
import 'package:openvine/services/video_thumbnail_service.dart';
import 'package:openvine/services/zendesk_support_service.dart';
import 'package:openvine/startup/database_bootstrap_failure_app.dart';
import 'package:openvine/startup/image_cache_budget.dart';
import 'package:openvine/startup/startup_coordinator_factory.dart';
import 'package:openvine/startup/window_size_constants.dart';
import 'package:openvine/utils/app_uptime.dart';
Expand Down Expand Up @@ -189,12 +190,6 @@ Future<void> startOpenVineApp({
// they appear on the in-app Open Source Licenses page. See #7206.
registerShorebirdLicenses();

// Give the in-memory image cache a larger byte budget than Flutter's 100 MB
// default so thumbnails survive fast scrolling instead of being evicted and
// reloaded. Per-thumbnail memory is bounded by decoding at display size.
PaintingBinding.instance.imageCache.maximumSizeBytes =
AppConstants.imageCacheMaxBytes;

// Keep the native splash visible until startup auth reaches a terminal
// state. The release watcher is installed after the ProviderContainer exists
// so the UI/native concern stays in app startup instead of AuthService.
Expand Down Expand Up @@ -237,6 +232,13 @@ Future<void> startOpenVineApp({
disposeAll: DivineVideoPlayerController.disposeAll,
);

// Size the decoded-image cache by device RAM so thumbnails survive fast
// scrolling without the cache's GPU cost crowding out smaller devices.
final imageCacheBudget = await configureImageCacheBudget(
cache: PaintingBinding.instance.imageCache,
readPhysicalMemoryBytes: const DeviceMemoryService().physicalMemoryBytes,
);

startupPerformance.completePhase('bindings');

// Initialize crash reporting ASAP so we can use it for logging
Expand All @@ -250,6 +252,11 @@ Future<void> startOpenVineApp({
name: 'Main',
category: LogCategory.system,
);
Log.info(
'[STARTUP] Image cache budget ${imageCacheBudget >> 20} MB',
name: 'Main',
category: LogCategory.system,
);
// Static utility classes have no constructor to inject through, so their
// reporter seams are assigned here. Without this they keep their silent
// default and their reports are lost (#4743).
Expand Down
41 changes: 41 additions & 0 deletions mobile/lib/startup/image_cache_budget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// ABOUTME: Sizes Flutter's decoded-image cache by the device's physical RAM,
// ABOUTME: so its GPU cost stays a similar share of memory on every device.

import 'package:flutter/painting.dart';
import 'package:openvine/constants/app_constants.dart';

const int _bytesPerGib = 1 << 30;

/// Decoded-image cache budget for a device with [physicalMemoryBytes] of RAM.
///
/// RAM divided by [AppConstants.imageCacheRamDivisor], clamped to
/// [AppConstants.imageCacheMinBytes]–[AppConstants.imageCacheMaxBytes]:
/// 12 GB gets 256 MB, 8 GB 170 MB, 6 GB 128 MB and 4 GB or less 96 MB.
/// Unknown RAM gets [AppConstants.imageCacheFallbackBytes].
///
/// The OS reports a little less than the installed RAM (a 12 GB Galaxy S26
/// reports 11.4 GiB), so the reading is rounded up to whole GiB first.
int imageCacheBudgetBytes(int? physicalMemoryBytes) {
if (physicalMemoryBytes == null || physicalMemoryBytes <= 0) {
return AppConstants.imageCacheFallbackBytes;
}
final installedGib = (physicalMemoryBytes + _bytesPerGib - 1) ~/ _bytesPerGib;
final budget =
installedGib * _bytesPerGib ~/ AppConstants.imageCacheRamDivisor;
return budget.clamp(
AppConstants.imageCacheMinBytes,
AppConstants.imageCacheMaxBytes,
);
}

/// Sets [cache]'s byte budget from the RAM [readPhysicalMemoryBytes] reports.
///
/// Returns the budget it set.
Future<int> configureImageCacheBudget({
required ImageCache cache,
required Future<int?> Function() readPhysicalMemoryBytes,
}) async {
final budget = imageCacheBudgetBytes(await readPhysicalMemoryBytes());
cache.maximumSizeBytes = budget;
return budget;
}
27 changes: 27 additions & 0 deletions mobile/lib/utils/decoded_image_cache.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// ABOUTME: Empties Flutter's decoded-image cache and schedules a frame, so
// ABOUTME: the GPU memory behind the cached images is actually given back.

import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

/// Empties Flutter's decoded-image cache and schedules a frame.
///
/// [ImageCache] releases each image it drops in a post-frame callback, so
/// nothing it held is freed until a frame is drawn, on any renderer, and a
/// static screen draws none. With a full cache, graphics memory stayed at
/// 690 MB for 30 s after a clear on a Galaxy S26 and fell to 386 MB once
/// frames ran, and an iPhone 12 Pro fell from 824 to 552 MB (#10301).
///
/// [includeLiveImages] also stops tracking images that are still on screen.
/// They stay visible, and are freed when the widgets showing them let go.
///
/// The frame follows the app's lifecycle, so a clear while the app is hidden
/// or paused frees nothing until it returns. On a Galaxy S26 the
/// memory-pressure clear on backgrounding arrived while the app was still
/// inactive, so its frame was drawn and graphics memory in the background
/// fell from 632 MB to 254–270 MB. Android does not guarantee that order.
void clearDecodedImageCache({bool includeLiveImages = false}) {
final cache = PaintingBinding.instance.imageCache..clear();
if (includeLiveImages) cache.clearLiveImages();
SchedulerBinding.instance.scheduleFrame();
}
16 changes: 10 additions & 6 deletions mobile/lib/utils/open_vine_image_cache.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import 'package:flutter/painting.dart';
import 'package:flutter/foundation.dart';
import 'package:media_cache/media_cache.dart';
import 'package:openvine/utils/decoded_image_cache.dart';

/// Global image cache singleton backed by [MediaCacheManager].
final openVineImageCache = MediaCacheManager(
config: const MediaCacheConfig.image(cacheKey: 'openvine_image_cache'),
);

/// Clears Flutter's in-memory image caches and the shared thumbnail disk cache.
Future<void> clearOpenVineImageCache() async {
///
/// [diskCache] replaces [openVineImageCache] in tests, which keep the real
/// on-disk cache out of the shared test isolate.
Future<void> clearOpenVineImageCache({
@visibleForTesting MediaCacheManager? diskCache,
}) async {
// Clear all image entries, not only thumbnails, so revoked adult-media auth
// cannot survive in live ImageProviders or shared cache-manager files.
final imageCache = PaintingBinding.instance.imageCache;
imageCache.clear();
imageCache.clearLiveImages();
await openVineImageCache.clearCache();
clearDecodedImageCache(includeLiveImages: true);
await (diskCache ?? openVineImageCache).clearCache();
}
Loading
Loading