import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; final class RemoteConnectionsService { const RemoteConnectionsService({http.Client? client}) : _client = client; static Future? _cachedEndpoint; final http.Client? _client; Future> fetchConfigLinks() async { final Uri uri = await _connectionsUri(); final http.Client client = _client ?? http.Client(); try { final http.Response response = await client .get( uri, headers: const {'accept': 'application/json'}, ) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { throw RemoteConnectionsException( 'Remote server returned ${response.statusCode} for GET /connections', ); } final dynamic decoded = jsonDecode(response.body); final List links = _extractLinks(decoded); return links.toSet().toList(); } on TimeoutException { throw const RemoteConnectionsException('Remote request timeout'); } on FormatException { throw const RemoteConnectionsException( 'Remote response is not valid JSON', ); } finally { if (_client == null) { client.close(); } } } Future addConfigLink(String link) async { final String normalized = link.trim(); if (normalized.isEmpty) { throw const RemoteConnectionsException('Config link is empty'); } final Uri uri = await _connectionsUri(); final http.Client client = _client ?? http.Client(); try { final http.Response response = await client .post( uri, headers: const { 'accept': 'application/json', 'content-type': 'application/json', }, body: jsonEncode({'url': normalized}), ) .timeout(const Duration(seconds: 15)); if (response.statusCode == 201) { return; } if (response.statusCode == 409) { throw const RemoteConnectionsException('Remote link already exists'); } if (response.statusCode == 400) { throw const RemoteConnectionsException( 'Remote server rejected payload', ); } throw RemoteConnectionsException( 'Remote server returned ${response.statusCode} for POST /connections', ); } on TimeoutException { throw const RemoteConnectionsException('Remote request timeout'); } finally { if (_client == null) { client.close(); } } } Future deleteConfigLink(String link) async { final String normalized = link.trim(); if (normalized.isEmpty) { throw const RemoteConnectionsException('Config link is empty'); } final Uri uri = await _connectionsUri(); final http.Client client = _client ?? http.Client(); try { final http.Response response = await client .delete( uri, headers: const { 'accept': 'application/json', 'content-type': 'application/json', }, body: jsonEncode({'url': normalized}), ) .timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { return; } if (response.statusCode == 404) { throw const RemoteConnectionsException('Remote link not found'); } if (response.statusCode == 400) { throw const RemoteConnectionsException( 'Remote server rejected payload', ); } throw RemoteConnectionsException( 'Remote server returned ${response.statusCode} for DELETE /connections', ); } on TimeoutException { throw const RemoteConnectionsException('Remote request timeout'); } finally { if (_client == null) { client.close(); } } } Future _connectionsUri() async { final String endpoint = (await _getEndpoint()).trim(); if (endpoint.isEmpty) { throw const RemoteConnectionsException( 'REMOTE_CONFIGS_URL is not configured in assets/env', ); } final Uri uri; try { uri = Uri.parse(endpoint); } catch (_) { throw RemoteConnectionsException( 'Invalid REMOTE_CONFIGS_URL in assets/env: $endpoint', ); } final String normalizedPath; if (uri.path.isEmpty || uri.path == '/') { normalizedPath = '/connections'; } else { normalizedPath = uri.path; } return uri.replace(path: normalizedPath); } Future _getEndpoint() { return _cachedEndpoint ??= _loadEndpoint(); } Future _loadEndpoint() async { final String content; try { content = await rootBundle.loadString('assets/env'); } catch (_) { throw const RemoteConnectionsException('Failed to load assets/env'); } final Map env = _parseEnv(content); return env['REMOTE_CONFIGS_URL'] ?? ''; } Map _parseEnv(String source) { final Map values = {}; final List lines = const LineSplitter().convert(source); for (final String line in lines) { final String trimmed = line.trim(); if (trimmed.isEmpty || trimmed.startsWith('#')) { continue; } final int separator = trimmed.indexOf('='); if (separator <= 0) { continue; } final String key = trimmed.substring(0, separator).trim(); String value = trimmed.substring(separator + 1).trim(); if (value.length >= 2) { final bool quotedWithDouble = value.startsWith('"') && value.endsWith('"'); final bool quotedWithSingle = value.startsWith("'") && value.endsWith("'"); if (quotedWithDouble || quotedWithSingle) { value = value.substring(1, value.length - 1); } } if (key.isNotEmpty) { values[key] = value; } } return values; } List _extractLinks(dynamic data) { if (data is! Map) { throw const RemoteConnectionsException( 'Invalid payload: expected {"links": ["..."]}', ); } final Map map = Map.from(data); final dynamic linksPayload = map['links']; if (linksPayload is! List) { throw const RemoteConnectionsException( 'Invalid payload: expected {"links": ["..."]}', ); } final List links = []; for (final dynamic item in linksPayload) { if (item is! String) { throw const RemoteConnectionsException( 'Invalid payload: expected {"links": ["..."]}', ); } final String link = item.trim(); if (link.isNotEmpty) { links.add(link); } } return links; } } final class RemoteConnectionsException implements Exception { const RemoteConnectionsException(this.message); final String message; @override String toString() => message; }