From d79dfffad3950a4a119a9a6a5066637a8803cded Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 10 Aug 2026 01:54:59 -0400 Subject: [PATCH] feat(#549): CoreScope HTTP observer-count query service Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope instance (default map.okimesh.org); returns observer_count or null. Never throws, so the chat UI degrades silently to radio-only when offline or the hash is unknown. Part of epic #524. Agent: QuietSnow (session 31eaba02) --- lib/services/corescope_service.dart | 66 ++++++++++++++++++++ test/services/corescope_service_test.dart | 75 +++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 lib/services/corescope_service.dart create mode 100644 test/services/corescope_service_test.dart diff --git a/lib/services/corescope_service.dart b/lib/services/corescope_service.dart new file mode 100644 index 0000000..6ea6966 --- /dev/null +++ b/lib/services/corescope_service.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../utils/app_logger.dart'; + +/// Default CoreScope instance (OKIMesh). Owner-run; its read API is public. +const String kDefaultCoreScopeHost = 'map.okimesh.org'; + +/// Queries a CoreScope instance for how many observers reported a given packet, +/// keyed by the firmware/mesh packet hash (#524). Read-only and best-effort: +/// any failure (offline, timeout, non-200, bad body, unknown packet) returns +/// null so the chat UI can silently fall back to radio-only. Never throws. +class CoreScopeService { + CoreScopeService({ + http.Client? client, + this.host = kDefaultCoreScopeHost, + this.useTls = true, + this.timeout = const Duration(seconds: 6), + }) : _client = client ?? http.Client(); + + final http.Client _client; + final String host; + final bool useTls; + final Duration timeout; + + /// Unique observers that reported the packet with [packetHash] + /// (16 lowercase hex chars). Returns null on any error, or when CoreScope + /// has no record of the hash yet. + Future fetchObserverCount(String packetHash) async { + if (packetHash.isEmpty) return null; + final uri = Uri( + scheme: useTls ? 'https' : 'http', + host: host, + path: '/api/packets', + queryParameters: { + 'hash': packetHash, + 'groupByHash': 'true', + 'limit': '1', + }, + ); + try { + final resp = await _client.get(uri).timeout(timeout); + if (resp.statusCode != 200) { + appLogger.warn( + 'HTTP ${resp.statusCode} for hash $packetHash', + tag: 'CoreScope', + ); + return null; + } + final body = jsonDecode(resp.body); + if (body is! Map) return null; + final packets = body['packets']; + if (packets is! List || packets.isEmpty) return null; + final first = packets.first; + if (first is! Map) return null; + final count = first['observer_count']; + return count is num ? count.toInt() : null; + } catch (e) { + appLogger.warn('Query failed for $packetHash: $e', tag: 'CoreScope'); + return null; + } + } + + void dispose() => _client.close(); +} diff --git a/test/services/corescope_service_test.dart b/test/services/corescope_service_test.dart new file mode 100644 index 0000000..2c9e0c7 --- /dev/null +++ b/test/services/corescope_service_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:meshcore_open/services/corescope_service.dart'; + +void main() { + group('CoreScopeService.fetchObserverCount', () { + test('parses observer_count from a grouped-by-hash response', () async { + late Uri captured; + final service = CoreScopeService( + host: 'map.okimesh.org', + client: MockClient((req) async { + captured = req.url; + return http.Response( + '{"packets":[{"hash":"abc123","observer_count":7}],"total":1}', + 200, + ); + }), + ); + + expect(await service.fetchObserverCount('abc123'), 7); + // Correct endpoint + query built from the hash. + expect(captured.scheme, 'https'); + expect(captured.host, 'map.okimesh.org'); + expect(captured.path, '/api/packets'); + expect(captured.queryParameters['hash'], 'abc123'); + expect(captured.queryParameters['groupByHash'], 'true'); + }); + + test('returns null when CoreScope has no record (empty packets)', () async { + final service = CoreScopeService( + client: MockClient( + (_) async => http.Response('{"packets":[],"total":0}', 200), + ), + ); + expect(await service.fetchObserverCount('deadbeef'), isNull); + }); + + test('returns null on non-200', () async { + final service = CoreScopeService( + client: MockClient((_) async => http.Response('nope', 503)), + ); + expect(await service.fetchObserverCount('abc123'), isNull); + }); + + test('returns null on malformed body', () async { + final service = CoreScopeService( + client: MockClient((_) async => http.Response('not json', 200)), + ); + expect(await service.fetchObserverCount('abc123'), isNull); + }); + + test('returns null (never throws) on transport failure', () async { + final service = CoreScopeService( + client: MockClient((_) async => throw Exception('offline')), + ); + expect(await service.fetchObserverCount('abc123'), isNull); + }); + + test( + 'returns null for an empty hash without hitting the network', + () async { + var called = false; + final service = CoreScopeService( + client: MockClient((_) async { + called = true; + return http.Response('{}', 200); + }), + ); + expect(await service.fetchObserverCount(''), isNull); + expect(called, isFalse); + }, + ); + }); +}