diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 89e6e5b8..29cdcf87 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -22,6 +22,29 @@ bool isForegroundServiceStartNotAllowed(Object error) { Object? _decodeJsonInBackground(String json) => jsonDecode(json); String _encodeJsonInBackground(Object? value) => jsonEncode(value); +String _encodeBoundedLookupCacheInBackground(Map entries) { + final result = StringBuffer('{'); + var bytes = 2; // Opening and closing braces. + var count = 0; + for (final entry in entries.entries) { + final fragment = + '${_encodeJsonInBackground(entry.key)}:${_encodeJsonInBackground(entry.value)}'; + final entryBytes = utf8.encode(fragment).length; + final separatorBytes = count == 0 ? 0 : 1; + if (entryBytes > PlatformBridge._persistentCacheMaxEntryBytes || + bytes + separatorBytes + entryBytes > + PlatformBridge._persistentCacheMaxBytes) { + continue; + } + if (count > 0) result.write(','); + result.write(fragment); + bytes += separatorBytes + entryBytes; + count++; + } + result.write('}'); + return result.toString(); +} + Object? _decodeJsonFileInBackground(String path) { final contents = File(path).readAsStringSync(); return contents.isEmpty ? null : jsonDecode(contents); @@ -142,8 +165,13 @@ bool shouldResetRestoredInstallation({ class _BridgeCacheEntry { final Map value; final DateTime expiresAt; + final int bytes; - const _BridgeCacheEntry({required this.value, required this.expiresAt}); + const _BridgeCacheEntry({ + required this.value, + required this.expiresAt, + required this.bytes, + }); bool get isExpired => DateTime.now().isAfter(expiresAt); } @@ -151,8 +179,13 @@ class _BridgeCacheEntry { class _BridgeListCacheEntry { final List> value; final DateTime expiresAt; + final int bytes; - const _BridgeListCacheEntry({required this.value, required this.expiresAt}); + const _BridgeListCacheEntry({ + required this.value, + required this.expiresAt, + required this.bytes, + }); bool get isExpired => DateTime.now().isAfter(expiresAt); } @@ -190,6 +223,12 @@ class PlatformBridge { static const _urlHandleCacheTtl = Duration(minutes: 5); static const _customSearchCacheTtl = Duration(minutes: 2); static const _bridgeCacheMaxEntries = 256; + // Bound retained JSON containers as well as strings. Entry counts alone do + // not bound album/playlist responses, which can contain thousands of tracks. + static const _bridgeCacheMaxEntryBytes = 4 * 1024 * 1024; + static const _bridgeCacheMaxBytes = 8 * 1024 * 1024; + static const _persistentCacheMaxEntryBytes = 1024 * 1024; + static const _persistentCacheMaxBytes = 2 * 1024 * 1024; static const _lookupCachePersistDebounce = Duration(milliseconds: 500); static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1'; static const _downloadProgressEvents = EventChannel( @@ -397,15 +436,7 @@ class PlatformBridge { Duration ttl, String persistentCacheKey, ) { - _pruneExpiredBridgeCache(cache); - while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { - cache.remove(cache.keys.first); - } - cache[key] = _BridgeCacheEntry( - // Loader results stay private; every caller receives its own deep copy. - value: value, - expiresAt: DateTime.now().add(ttl), - ); + _putMemoryCachedMap(cache, key, value, ttl); _scheduleLookupCachePersist( cache, persistentCacheKey, @@ -413,6 +444,37 @@ class PlatformBridge { ); } + /// Conservative retained-size estimate, with early exit for oversized JSON. + /// Avoids allocating another encoded response just to decide cache admission. + static int _payloadBytes(Object? value) { + var bytes = 0; + void visit(Object? item) { + if (bytes > _bridgeCacheMaxEntryBytes) return; + if (item is String) { + bytes += 32 + item.length * 2; + } else if (item is Map) { + bytes += 64; + for (final entry in item.entries) { + bytes += 48; + visit(entry.key); + visit(entry.value); + if (bytes > _bridgeCacheMaxEntryBytes) break; + } + } else if (item is List) { + bytes += 32 + item.length * 8; + for (final entry in item) { + visit(entry); + if (bytes > _bridgeCacheMaxEntryBytes) break; + } + } else { + bytes += 16; + } + } + + visit(value); + return bytes; + } + static void _pruneExpiredBridgeCache(Map cache) { if (cache.isEmpty) return; final now = DateTime.now(); @@ -495,6 +557,10 @@ class PlatformBridge { ) async { final raw = prefs.getString(prefsKey); if (raw == null || raw.isEmpty) return; + if (raw.length > _persistentCacheMaxBytes) { + await prefs.remove(prefsKey); + return; + } final decoded = await _decodeJsonStringAsync(raw); if (generation != _lookupCacheGeneration) return; @@ -514,9 +580,17 @@ class PlatformBridge { final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs); if (!expiresAt.isAfter(now)) continue; + final map = Map.from(value); + final bytes = _payloadBytes(map) + key.length * 2; + if (bytes > _bridgeCacheMaxEntryBytes) continue; + if (target.values.fold(0, (sum, item) => sum + item.bytes) + bytes > + _bridgeCacheMaxBytes) { + break; + } target[key] = _BridgeCacheEntry( - value: Map.from(value), + value: map, expiresAt: expiresAt, + bytes: bytes, ); } } @@ -528,16 +602,25 @@ class PlatformBridge { ) async { try { _pruneExpiredBridgeCache(cache); - final data = { - for (final entry in cache.entries) - entry.key: { - 'expires_at': entry.value.expiresAt.millisecondsSinceEpoch, - 'value': entry.value.value, - }, - }; - final encoded = data.length >= 32 - ? await compute(_encodeJsonInBackground, data) - : jsonEncode(data); + final data = {}; + var bytes = 64; + for (final entry in cache.entries.toList().reversed) { + final cost = entry.value.bytes + 128; + if (cost > _persistentCacheMaxEntryBytes || + bytes + cost > _persistentCacheMaxBytes) { + continue; + } + bytes += cost; + data[entry.key] = { + 'expires_at': entry.value.expiresAt.millisecondsSinceEpoch, + 'value': entry.value.value, + }; + } + // Escaping and multibyte UTF-8 can outgrow the retained-size estimate. + // Enforce both encoded limits in the isolate, without UI-side encoding. + final encoded = bytes >= _backgroundJsonDecodeThresholdBytes + ? await compute(_encodeBoundedLookupCacheInBackground, data) + : _encodeBoundedLookupCacheInBackground(data); if (generation != _lookupCacheGeneration) return; final prefs = await SharedPreferences.getInstance(); if (generation != _lookupCacheGeneration) return; @@ -2054,12 +2137,19 @@ class PlatformBridge { Duration ttl, ) { _pruneExpiredBridgeCache(cache); - while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { - cache.remove(cache.keys.first); + cache.remove(key); + final bytes = _payloadBytes(value) + key.length * 2; + if (bytes > _bridgeCacheMaxEntryBytes) return; + var retained = cache.values.fold(0, (sum, item) => sum + item.bytes); + while (cache.isNotEmpty && + (cache.length >= _bridgeCacheMaxEntries || + retained + bytes > _bridgeCacheMaxBytes)) { + retained -= cache.remove(cache.keys.first)!.bytes; } cache[key] = _BridgeCacheEntry( value: value, expiresAt: DateTime.now().add(ttl), + bytes: bytes, ); } @@ -2084,12 +2174,19 @@ class PlatformBridge { Duration ttl, ) { _pruneExpiredBridgeListCache(cache); - while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { - cache.remove(cache.keys.first); + cache.remove(key); + final bytes = _payloadBytes(value) + key.length * 2; + if (bytes > _bridgeCacheMaxEntryBytes) return; + var retained = cache.values.fold(0, (sum, item) => sum + item.bytes); + while (cache.isNotEmpty && + (cache.length >= _bridgeCacheMaxEntries || + retained + bytes > _bridgeCacheMaxBytes)) { + retained -= cache.remove(cache.keys.first)!.bytes; } cache[key] = _BridgeListCacheEntry( value: value, expiresAt: DateTime.now().add(ttl), + bytes: bytes, ); } diff --git a/test/platform_bridge_custom_search_test.dart b/test/platform_bridge_custom_search_test.dart index 355241f8..1559d06e 100644 --- a/test/platform_bridge_custom_search_test.dart +++ b/test/platform_bridge_custom_search_test.dart @@ -13,6 +13,29 @@ void main() { tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + test( + 'oversized custom-search lists are displayed without accumulating', + () async { + final rows = [ + {'id': 'large', 'name': 'x' * (3 * 1024 * 1024)}, + ]; + final payload = jsonEncode(rows); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (_) async { + calls++; + return payload; + }); + for (var index = 0; index < 2; index++) { + final result = await PlatformBridge.customSearchWithExtension( + 'oversized-search-test', + 'large', + ); + expect(result, rows); + } + expect(calls, 2); + }, + ); + for (final format in ['small-json', 'large-json', 'native-list']) { test('custom search decodes and caches $format results', () async { final rows = List.generate( diff --git a/test/platform_bridge_lookup_cache_test.dart b/test/platform_bridge_lookup_cache_test.dart index 1e4eac4e..ca953bb0 100644 --- a/test/platform_bridge_lookup_cache_test.dart +++ b/test/platform_bridge_lookup_cache_test.dart @@ -41,6 +41,118 @@ void main() { Future> load() => PlatformBridge.getProviderMetadata('example', 'album', 'collection'); + test( + 'oversized responses are returned and coalesced but not retained', + () async { + final payload = jsonEncode({'text': 'x' * (3 * 1024 * 1024)}); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + calls++; + return payload; + }); + final results = await Future.wait([load(), load()]); + expect(calls, 1); + expect((results.first['text'] as String).length, 3 * 1024 * 1024); + results.first['text'] = 'changed'; + expect(results.last['text'], isNot('changed')); + expect((await load())['text'], isNot('changed')); + expect(calls, 2); + await Future.delayed(const Duration(milliseconds: 550)); + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString('bridge_metadata_lookup_cache_v1'); + expect(stored == null || jsonDecode(stored) is Map, true); + expect(stored ?? '', isNot(contains('collection'))); + }, + ); + + test('byte budget evicts old collections before the count limit', () async { + var calls = 0; + final payload = jsonEncode({'text': 'x' * (700 * 1024)}); + messenger.setMockMethodCallHandler(channel, (call) async { + calls++; + return payload; + }); + Future> collectionAt(int index) => + PlatformBridge.getProviderMetadata('example', 'album', 'album-$index'); + for (var index = 0; index < 8; index++) { + await collectionAt(index); + } + expect(calls, 8); + await collectionAt(7); + expect(calls, 8); + await collectionAt(0); + expect(calls, 9); + }); + + test( + 'persistence keeps small metadata and skips a large collection', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + final id = (call.arguments as Map)['resource_id']; + return jsonEncode({ + 'text': id == 'large' ? 'x' * (600 * 1024) : 'small', + }); + }); + await PlatformBridge.getProviderMetadata('example', 'album', 'large'); + await PlatformBridge.getProviderMetadata('example', 'track', 'small'); + await Future.delayed(const Duration(milliseconds: 650)); + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString('bridge_metadata_lookup_cache_v1')!; + expect(utf8.encode(stored).length, lessThanOrEqualTo(2 * 1024 * 1024)); + expect(jsonDecode(stored), contains('example:track:small')); + expect(jsonDecode(stored), isNot(contains('example:album:large'))); + }, + ); + + test( + 'persistence skips entries oversized after JSON escaping or UTF-8', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + final id = (call.arguments as Map)['resource_id']; + return jsonEncode({ + 'text': switch (id) { + 'escaped' => '\u0000' * 250000, + 'multibyte' => '音' * 380000, + _ => 'small', + }, + }); + }); + for (final id in ['escaped', 'multibyte', 'small']) { + await PlatformBridge.getProviderMetadata('example', 'album', id); + } + await Future.delayed(const Duration(milliseconds: 750)); + final prefs = await SharedPreferences.getInstance(); + final stored = + jsonDecode(prefs.getString('bridge_metadata_lookup_cache_v1')!) + as Map; + expect(stored.keys, ['example:album:small']); + }, + ); + + test('persistence bounds the total encoded multibyte payload', () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => jsonEncode({'text': '音' * 250000}), + ); + for (var index = 0; index < 3; index++) { + await PlatformBridge.getProviderMetadata('example', 'album', '$index'); + } + await Future.delayed(const Duration(milliseconds: 750)); + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('bridge_metadata_lookup_cache_v1')!; + expect(utf8.encode(raw).length, lessThanOrEqualTo(2 * 1024 * 1024)); + final stored = jsonDecode(raw) as Map; + expect(stored.keys, ['example:album:2', 'example:album:1']); + for (final entry in stored.entries) { + expect( + utf8 + .encode('${jsonEncode(entry.key)}:${jsonEncode(entry.value)}') + .length, + lessThanOrEqualTo(1024 * 1024), + ); + } + }); + void mutate(Map value) { (value['album_info'] as Map)['name'] = 'Changed'; final first = (value['track_list'] as List).first as Map;