From 2ce3c33c87b03b0f827deafb43cd013ca21befce Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:46 +0700 Subject: [PATCH] fix(network): close request-owned HTTP clients reliably --- lib/services/apk_downloader.dart | 9 ++++-- lib/services/app_remote_config_service.dart | 11 +++++-- test/app_remote_config_service_test.dart | 36 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart index 47905b7b..826f22e6 100644 --- a/lib/services/apk_downloader.dart +++ b/lib/services/apk_downloader.dart @@ -146,8 +146,13 @@ class ApkDownloader { _log.e('Update download paused after error: $e'); return null; } finally { - await sink?.close(); - if (ownedClient) effectiveClient.close(); + try { + await sink?.close(); + } catch (error) { + _log.w('Failed to close partial update file: $error'); + } finally { + if (ownedClient) effectiveClient.close(); + } } } diff --git a/lib/services/app_remote_config_service.dart b/lib/services/app_remote_config_service.dart index d7fe4a99..63679202 100644 --- a/lib/services/app_remote_config_service.dart +++ b/lib/services/app_remote_config_service.dart @@ -377,13 +377,13 @@ class AppRemoteConfigService { static const _dismissedAnnouncementIdsKey = 'app_remote_config_dismissed_announcement_ids'; - final http.Client _client; + final http.Client? _client; final String endpoint; AppRemoteConfigService({ http.Client? client, this.endpoint = AppInfo.remoteConfigApiUrl, - }) : _client = client ?? http.Client(); + }) : _client = client; Future readCachedConfig() async { final prefs = await SharedPreferences.getInstance(); @@ -396,6 +396,9 @@ class AppRemoteConfigService { } Future fetchConfigSnapshot({String? locale}) async { + // A default client belongs to this request; an injected client belongs to + // its caller. Cached config and dismissal helpers need no HTTP resources. + final client = _client ?? http.Client(); try { final uri = Uri.parse(endpoint).replace( queryParameters: { @@ -406,7 +409,7 @@ class AppRemoteConfigService { }, ); - final response = await _client + final response = await client .get( uri, headers: { @@ -443,6 +446,8 @@ class AppRemoteConfigService { } catch (e) { _log.w('Remote config fetch failed: $e'); return null; + } finally { + if (_client == null) client.close(); } } diff --git a/test/app_remote_config_service_test.dart b/test/app_remote_config_service_test.dart index d7da0e81..a8e412d0 100644 --- a/test/app_remote_config_service_test.dart +++ b/test/app_remote_config_service_test.dart @@ -11,6 +11,18 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +class _TrackedClient extends MockClient { + _TrackedClient(super.handler); + + bool closed = false; + + @override + void close() { + closed = true; + super.close(); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -18,6 +30,30 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + test('default request closes its client on success and failure', () async { + for (final fail in [false, true]) { + final client = _TrackedClient((_) async { + if (fail) throw StateError('network unavailable'); + return http.Response('{}', 200); + }); + final snapshot = await http.runWithClient( + () => AppRemoteConfigService().fetchConfigSnapshot(), + () => client, + ); + expect(snapshot, fail ? isNull : isNotNull); + expect(client.closed, isTrue); + } + }); + + test('injected client remains owned by its caller', () async { + final client = _TrackedClient((_) async => http.Response('{}', 200)); + final service = AppRemoteConfigService(client: client); + expect(await service.fetchConfigSnapshot(), isNotNull); + expect(client.closed, isFalse); + expect(await service.fetchConfigSnapshot(), isNotNull); + client.close(); + }); + test( 'parses monthly donation progress and display settings from the API', () {