perf(net): cache update-check and remote-config fetches with TTL and ETag

This commit is contained in:
zarzet
2026-07-14 09:10:02 +07:00
parent 547e6493dd
commit 937ff6c4a2
2 changed files with 70 additions and 11 deletions
+16 -1
View File
@@ -262,8 +262,11 @@ class AppRemoteConfigService {
static const _cachedConfigJsonKey = 'app_remote_config_cached_json';
static const _cachedConfigFetchedAtKey =
'app_remote_config_cached_fetched_at';
static const _lastFetchAttemptAtKey = 'app_remote_config_last_fetch_at';
static const _dismissedAnnouncementIdsKey =
'app_remote_config_dismissed_announcement_ids';
// Config changes are rare; don't re-hit the endpoint on every app open.
static const _fetchTtl = Duration(hours: 6);
final http.Client _client;
final String endpoint;
@@ -290,6 +293,15 @@ class AppRemoteConfigService {
Future<RemoteConfigSnapshot?> fetchConfigSnapshot({String? locale}) async {
try {
final prefs = await SharedPreferences.getInstance();
final lastFetch = DateTime.tryParse(
prefs.getString(_lastFetchAttemptAtKey) ?? '',
);
if (lastFetch != null &&
DateTime.now().difference(lastFetch) < _fetchTtl) {
return readCachedConfig();
}
final uri = Uri.parse(endpoint).replace(
queryParameters: {
'platform': Platform.isAndroid ? 'android' : Platform.operatingSystem,
@@ -311,7 +323,10 @@ class AppRemoteConfigService {
final snapshot = _parseSnapshot(response.body);
if (snapshot == null) return null;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_lastFetchAttemptAtKey,
DateTime.now().toIso8601String(),
);
final cachedJson = prefs.getString(_cachedConfigJsonKey);
if (cachedJson != snapshot.rawJson) {
await prefs.setString(_cachedConfigJsonKey, snapshot.rawJson);
+54 -10
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -52,25 +53,68 @@ class UpdateChecker {
/// update before continuing to use the app.
static const int forceUpdateThreshold = 3;
// The releases payload is tens of KB and update cadence is days, not
// minutes: serve from cache within the TTL and revalidate with ETag after,
// instead of re-downloading the full list on every cold start.
static const Duration _cacheTtl = Duration(hours: 6);
static const String _cachedBodyKey = 'update_checker_releases_json';
static const String _cachedAtKey = 'update_checker_releases_fetched_at';
static const String _cachedEtagKey = 'update_checker_releases_etag';
static Future<String?> _fetchReleasesBody() async {
final prefs = await SharedPreferences.getInstance();
final cachedBody = prefs.getString(_cachedBodyKey);
final cachedAt = prefs.getInt(_cachedAtKey) ?? 0;
final ageMs = DateTime.now().millisecondsSinceEpoch - cachedAt;
if (cachedBody != null && ageMs < _cacheTtl.inMilliseconds) {
return cachedBody;
}
final cachedEtag = prefs.getString(_cachedEtagKey);
final response = await http
.get(
Uri.parse('$_allReleasesApiUrl?per_page=30'),
headers: {
'Accept': 'application/vnd.github.v3+json',
if (cachedBody != null && cachedEtag != null)
'If-None-Match': cachedEtag,
},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 304 && cachedBody != null) {
await prefs.setInt(
_cachedAtKey,
DateTime.now().millisecondsSinceEpoch,
);
return cachedBody;
}
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return cachedBody; // stale is better than none for the update prompt
}
await prefs.setString(_cachedBodyKey, response.body);
final etag = response.headers['etag'];
if (etag != null) {
await prefs.setString(_cachedEtagKey, etag);
}
await prefs.setInt(_cachedAtKey, DateTime.now().millisecondsSinceEpoch);
return response.body;
}
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
if (!Platform.isAndroid) {
return null;
}
try {
final response = await http
.get(
Uri.parse('$_allReleasesApiUrl?per_page=30'),
headers: {'Accept': 'application/vnd.github.v3+json'},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
final releasesJson = await _fetchReleasesBody();
if (releasesJson == null) {
return null;
}
final releases = (jsonDecode(response.body) as List<dynamic>)
final releases = (jsonDecode(releasesJson) as List<dynamic>)
.whereType<Map<String, dynamic>>()
.toList();
if (releases.isEmpty) {