diff --git a/modules/ensemble/lib/framework/definition_providers/cdn_provider.dart b/modules/ensemble/lib/framework/definition_providers/cdn_provider.dart index 1e955bd97..48399a432 100644 --- a/modules/ensemble/lib/framework/definition_providers/cdn_provider.dart +++ b/modules/ensemble/lib/framework/definition_providers/cdn_provider.dart @@ -49,6 +49,9 @@ class CdnDefinitionProvider extends DefinitionProvider { // Background update tracking bool _hasPendingUpdate = false; + // Serialize overlapping refresh calls (cold start + lifecycle background). + Future _refreshSerial = Future.value(); + // Persistent cache key String get _artifactCacheKey => 'cdn_provider_state_$appId'; @@ -251,19 +254,24 @@ class CdnDefinitionProvider extends DefinitionProvider { } } - Future _saveCachedState(String manifestJson) async { - // Fire-and-forget to avoid blocking UI - unawaited(() async { - try { - final prefs = await SharedPreferences.getInstance(); - final etagVal = _etag ?? ''; - final lastVal = (_lastUpdatedAt ?? 0).toString(); - await prefs - .setStringList(_artifactCacheKey, [etagVal, lastVal, manifestJson]); - } catch (e) { - debugPrint('CdnProvider: Failed to save cached state: $e'); - } - }()); + Future _saveCachedState( + String manifestJson, { + required String? etag, + required int? lastUpdatedAt, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _artifactCacheKey, + cdnPersistedCacheEntry( + etag: etag, + lastUpdatedAt: lastUpdatedAt, + manifestJson: manifestJson, + ), + ); + } catch (e) { + debugPrint('CdnProvider: Failed to save cached state: $e'); + } } Future _clearCache() async { @@ -490,7 +498,15 @@ class CdnDefinitionProvider extends DefinitionProvider { /// Check for updates and update cache if available /// Sets _hasPendingUpdate flag if updates were fetched - Future _refreshIfStale() async { + Future _refreshIfStale() { + final refresh = _doRefreshIfStale(); + _refreshSerial = _refreshSerial + .then((_) => refresh) + .catchError((_) {}); + return refresh; + } + + Future _doRefreshIfStale() async { try { final shouldFetch = await _shouldFetchManifest(); if (!shouldFetch) { @@ -508,10 +524,16 @@ class CdnDefinitionProvider extends DefinitionProvider { _rebuildFromRoot(root); await _refreshTranslationsAtRuntime(); - _etag = newEtag ?? _etag; - - // Save to persistent cache - await _saveCachedState(jsonString); + final savedEtag = newEtag ?? _etag; + final savedLastUpdatedAt = _lastUpdatedAt; + _etag = savedEtag; + + // Save to persistent cache (snapshot etag/timestamp with manifest body) + await _saveCachedState( + jsonString, + etag: savedEtag, + lastUpdatedAt: savedLastUpdatedAt, + ); // If artifact refresh is enabled and app is already initialized, // immediately update appBundle and fire refresh event. @@ -559,13 +581,19 @@ class CdnDefinitionProvider extends DefinitionProvider { final jsonString = fetched['json'] as String?; if (jsonString == null) return; - _etag = fetched['etag'] as String?; + final savedEtag = fetched['etag'] as String?; + final savedLastUpdatedAt = _lastUpdatedAt; + _etag = savedEtag; final root = _decodeManifestRoot(jsonString); _rebuildFromRoot(root); - // Save to persistent cache - await _saveCachedState(jsonString); + // Save to persistent cache (snapshot etag/timestamp with manifest body) + await _saveCachedState( + jsonString, + etag: savedEtag, + lastUpdatedAt: savedLastUpdatedAt, + ); } Future _shouldFetchManifest() async { @@ -857,6 +885,16 @@ class CdnDefinitionProvider extends DefinitionProvider { static bool _isIncomingNewer(int? incoming, int? current) => incoming != null && (current == null || incoming > current); + /// SharedPreferences tuple for CDN cache; etag and timestamp must match + /// [manifestJson] or cold-start If-None-Match can serve a mismatched body. + @visibleForTesting + static List cdnPersistedCacheEntry({ + required String? etag, + required int? lastUpdatedAt, + required String manifestJson, + }) => + [etag ?? '', (lastUpdatedAt ?? 0).toString(), manifestJson]; + static Map? _asMap(dynamic value) { if (value is Map) return value; if (value is Map) return Map.from(value); @@ -942,6 +980,21 @@ class CdnDefinitionProvider extends DefinitionProvider { @visibleForTesting int? get lastUpdatedAtForTesting => _lastUpdatedAt; + @visibleForTesting + Future saveCachedStateForTesting( + String manifestJson, { + required String? etag, + required int? lastUpdatedAt, + }) => + _saveCachedState( + manifestJson, + etag: etag, + lastUpdatedAt: lastUpdatedAt, + ); + + @visibleForTesting + Future refreshIfStaleForTesting() => _refreshIfStale(); + Future _refreshTranslationsAtRuntime() async { try { final context = Utils.globalAppKey.currentContext; diff --git a/modules/ensemble/test/cdn_provider_test.dart b/modules/ensemble/test/cdn_provider_test.dart index 8178cbe19..ec3b9b586 100644 --- a/modules/ensemble/test/cdn_provider_test.dart +++ b/modules/ensemble/test/cdn_provider_test.dart @@ -11,6 +11,43 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + group('CDN persisted cache tuple', () { + test('cdnPersistedCacheEntry pairs manifest with snapshot metadata', () { + expect( + CdnDefinitionProvider.cdnPersistedCacheEntry( + etag: 'etag-a', + lastUpdatedAt: 42, + manifestJson: '{"artifacts":{}}', + ), + ['etag-a', '42', '{"artifacts":{}}'], + ); + }); + + test('saveCachedState persists passed etag instead of later instance value', + () async { + const appId = 'snapshot-etag-app'; + const cacheKey = 'cdn_provider_state_$appId'; + SharedPreferences.setMockInitialValues({}); + + final provider = CdnDefinitionProvider(appId); + await provider.saveCachedStateForTesting( + '{"manifest":"a"}', + etag: 'etag-a', + lastUpdatedAt: 100, + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getStringList(cacheKey), ['etag-a', '100', '{"manifest":"a"}']); + + await provider.saveCachedStateForTesting( + '{"manifest":"b"}', + etag: 'etag-b', + lastUpdatedAt: 200, + ); + expect(prefs.getStringList(cacheKey), ['etag-b', '200', '{"manifest":"b"}']); + }); + }); + group('CDN cache invalidation', () { test('resets freshness metadata when persisted manifest is invalid', () async {