diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 9a042631..916f77ae 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -9,7 +9,9 @@ import 'package:spotiflac_android/widgets/donate_icons.dart'; import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; class DonatePage extends StatefulWidget { - const DonatePage({super.key}); + final AppRemoteConfigService? remoteConfigService; + + const DonatePage({super.key, this.remoteConfigService}); @override State createState() => _DonatePageState(); @@ -26,11 +28,11 @@ class _DonatePageState extends State { if (_hasRequestedConfig) return; _hasRequestedConfig = true; - _loadConfig(Localizations.localeOf(context).toLanguageTag()); + unawaited(_loadConfig(Localizations.localeOf(context).toLanguageTag())); } Future _loadConfig(String locale) async { - final service = AppRemoteConfigService(); + final service = widget.remoteConfigService ?? AppRemoteConfigService(); final cached = await service.readCachedConfig(); if (!mounted) return; @@ -38,11 +40,9 @@ class _DonatePageState extends State { _applyRemoteConfig(cached); } - unawaited(_refreshConfigCache(locale)); - } - - Future _refreshConfigCache(String locale) async { - await AppRemoteConfigService().fetchConfigSnapshot(locale: locale); + final refreshed = await service.fetchConfigSnapshot(locale: locale); + if (!mounted || refreshed == null) return; + _applyRemoteConfig(refreshed); } void _applyRemoteConfig(RemoteConfigSnapshot snapshot) { diff --git a/lib/services/app_remote_config_service.dart b/lib/services/app_remote_config_service.dart index 168e87fa..223a43f4 100644 --- a/lib/services/app_remote_config_service.dart +++ b/lib/services/app_remote_config_service.dart @@ -262,11 +262,8 @@ 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; @@ -293,15 +290,6 @@ 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, @@ -323,10 +311,7 @@ class AppRemoteConfigService { final snapshot = _parseSnapshot(response.body); if (snapshot == null) return null; - await prefs.setString( - _lastFetchAttemptAtKey, - DateTime.now().toIso8601String(), - ); + final prefs = await SharedPreferences.getInstance(); final cachedJson = prefs.getString(_cachedConfigJsonKey); if (cachedJson != snapshot.rawJson) { await prefs.setString(_cachedConfigJsonKey, snapshot.rawJson); diff --git a/test/app_remote_config_service_test.dart b/test/app_remote_config_service_test.dart new file mode 100644 index 00000000..7ebedbad --- /dev/null +++ b/test/app_remote_config_service_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/screens/settings/donate_page.dart'; +import 'package:spotiflac_android/services/app_remote_config_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test( + 'refreshes remote config despite a legacy recent-fetch timestamp', + () async { + SharedPreferences.setMockInitialValues({ + 'app_remote_config_cached_json': jsonEncode( + _configPayload( + announcementId: 'cached-announcement', + donateTitle: 'Cached donation', + ), + ), + 'app_remote_config_last_fetch_at': DateTime.now().toIso8601String(), + }); + + var requestCount = 0; + final service = AppRemoteConfigService( + endpoint: 'https://example.test/config', + client: MockClient((request) async { + requestCount++; + return http.Response( + jsonEncode( + _configPayload( + announcementId: 'fresh-announcement', + donateTitle: 'Fresh donation', + ), + ), + 200, + ); + }), + ); + + final snapshot = await service.fetchConfigSnapshot(locale: 'en-US'); + + expect(requestCount, 1); + expect(snapshot?.changed, isTrue); + expect(snapshot?.config.announcement?.id, 'fresh-announcement'); + expect(snapshot?.config.donate.title, 'Fresh donation'); + }, + ); + + test('checks the endpoint again for each remote config request', () async { + var requestCount = 0; + final service = AppRemoteConfigService( + endpoint: 'https://example.test/config', + client: MockClient((request) async { + requestCount++; + return http.Response( + jsonEncode( + _configPayload( + announcementId: 'announcement-$requestCount', + donateTitle: 'Donation $requestCount', + ), + ), + 200, + ); + }), + ); + + final first = await service.fetchConfigSnapshot(locale: 'en-US'); + final second = await service.fetchConfigSnapshot(locale: 'en-US'); + + expect(requestCount, 2); + expect(first?.config.announcement?.id, 'announcement-1'); + expect(second?.config.announcement?.id, 'announcement-2'); + }); + + testWidgets('donate page applies a refreshed config without reopening', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'app_remote_config_cached_json': jsonEncode( + _configPayload( + announcementId: 'cached-announcement', + donateTitle: 'Cached donation', + ), + ), + }); + + final response = Completer(); + final service = AppRemoteConfigService( + endpoint: 'https://example.test/config', + client: MockClient((request) => response.future), + ); + + await tester.pumpWidget( + MaterialApp(home: DonatePage(remoteConfigService: service)), + ); + await tester.pump(); + + expect(find.text('Cached donation'), findsOneWidget); + + response.complete( + http.Response( + jsonEncode( + _configPayload( + announcementId: 'fresh-announcement', + donateTitle: 'Fresh donation', + ), + ), + 200, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Cached donation'), findsNothing); + expect(find.text('Fresh donation'), findsOneWidget); + }); +} + +Map _configPayload({ + required String announcementId, + required String donateTitle, +}) { + return { + 'announcement': { + 'id': announcementId, + 'enabled': true, + 'title': 'Announcement', + 'message': 'Announcement message', + }, + 'donate': { + 'enabled': true, + 'title': donateTitle, + 'message': 'Donation message', + 'methods': [ + { + 'id': 'support', + 'title': 'Support', + 'subtitle': 'Support this project', + 'url': 'https://example.test/support', + }, + ], + 'supporters': [], + 'notices': ['Donation notice'], + }, + }; +}