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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ class CdnDefinitionProvider extends DefinitionProvider {
// Background update tracking
bool _hasPendingUpdate = false;

// Serialize overlapping refresh calls (cold start + lifecycle background).
Future<void> _refreshSerial = Future.value();

// Persistent cache key
String get _artifactCacheKey => 'cdn_provider_state_$appId';

Expand Down Expand Up @@ -251,19 +254,24 @@ class CdnDefinitionProvider extends DefinitionProvider {
}
}

Future<void> _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<void> _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<void> _clearCache() async {
Expand Down Expand Up @@ -490,7 +498,15 @@ class CdnDefinitionProvider extends DefinitionProvider {

/// Check for updates and update cache if available
/// Sets _hasPendingUpdate flag if updates were fetched
Future<void> _refreshIfStale() async {
Future<void> _refreshIfStale() {
final refresh = _doRefreshIfStale();
_refreshSerial = _refreshSerial
.then((_) => refresh)
.catchError((_) {});
return refresh;
}

Future<void> _doRefreshIfStale() async {
try {
final shouldFetch = await _shouldFetchManifest();
if (!shouldFetch) {
Expand All @@ -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.
Expand Down Expand Up @@ -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<bool> _shouldFetchManifest() async {
Expand Down Expand Up @@ -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<String> cdnPersistedCacheEntry({
required String? etag,
required int? lastUpdatedAt,
required String manifestJson,
}) =>
[etag ?? '', (lastUpdatedAt ?? 0).toString(), manifestJson];

static Map<String, dynamic>? _asMap(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return Map<String, dynamic>.from(value);
Expand Down Expand Up @@ -942,6 +980,21 @@ class CdnDefinitionProvider extends DefinitionProvider {
@visibleForTesting
int? get lastUpdatedAtForTesting => _lastUpdatedAt;

@visibleForTesting
Future<void> saveCachedStateForTesting(
String manifestJson, {
required String? etag,
required int? lastUpdatedAt,
}) =>
_saveCachedState(
manifestJson,
etag: etag,
lastUpdatedAt: lastUpdatedAt,
);

@visibleForTesting
Future<void> refreshIfStaleForTesting() => _refreshIfStale();

Future<void> _refreshTranslationsAtRuntime() async {
try {
final context = Utils.globalAppKey.currentContext;
Expand Down
37 changes: 37 additions & 0 deletions modules/ensemble/test/cdn_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading