From 937ff6c4a287421344c55f886febe4d4aa15a2fa Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 14 Jul 2026 07:13:10 +0700 Subject: [PATCH] perf(net): cache update-check and remote-config fetches with TTL and ETag --- lib/services/app_remote_config_service.dart | 17 +++++- lib/services/update_checker.dart | 64 +++++++++++++++++---- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/lib/services/app_remote_config_service.dart b/lib/services/app_remote_config_service.dart index 223a43f4..168e87fa 100644 --- a/lib/services/app_remote_config_service.dart +++ b/lib/services/app_remote_config_service.dart @@ -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 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); diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 1b333c15..54dff61b 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -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 _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 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) + final releases = (jsonDecode(releasesJson) as List) .whereType>() .toList(); if (releases.isEmpty) {