fix(#678): distinguish CoreScope not-found from unreachable (#571)

CoreScopeService.fetchCounts returns a three-state CoreScopeResult { found | notFound | unreachable } instead of a nullable count. Reachable-but-empty -> notFound ('Not seen by CoreScope yet'); network/HTTP/parse error -> unreachable. Channel-chat tap-refresh banner switches on status; adaptive poll reads .counts.

Epic #677. Closes #571.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix/589-late-reply-log-only
Strycher 2 months ago committed by Benjamin Wiechel
parent edffd10555
commit d27048f94a

@ -4948,7 +4948,9 @@ class MeshCoreConnector extends ChangeNotifier {
for (final secs in _coreScopePollSecs) {
await Future<void>.delayed(Duration(seconds: secs));
if (!isConnected) return;
final counts = await _coreScopeService.fetchCounts(pkt.hashHex);
final counts = (await _coreScopeService.fetchCounts(
pkt.hashHex,
)).counts;
if (counts == null) continue;
if (counts.observers > best) {
best = counts.observers;
@ -4979,26 +4981,28 @@ class MeshCoreConnector extends ChangeNotifier {
}
/// Tap-to-refresh: re-query CoreScope for a message's stored on-air hash and
/// bump the counts if they grew (they only ever climb). Returns the fetched
/// counts (or null on failure / no stored hash) so the UI can confirm.
Future<CoreScopeCounts?> refreshCoreScopeCounts(
/// bump the counts if they grew (they only ever climb). Returns the full
/// result so the UI can distinguish found / notFound / unreachable (#571).
Future<CoreScopeResult> refreshCoreScopeCounts(
int channelIndex,
String messageId,
String hashHex,
) async {
final counts = await _coreScopeService.fetchCounts(hashHex);
if (counts == null) return null;
_updateChannelMessageById(channelIndex, messageId, (m) {
final current = m.coreScopeObserverCount ?? 0;
return counts.observers >= current
? m.copyWith(
coreScopeObserverCount: counts.observers,
coreScopeObservationCount: counts.observations,
)
: m;
});
notifyListeners();
return counts;
final result = await _coreScopeService.fetchCounts(hashHex);
final counts = result.counts;
if (counts != null) {
_updateChannelMessageById(channelIndex, messageId, (m) {
final current = m.coreScopeObserverCount ?? 0;
return counts.observers >= current
? m.copyWith(
coreScopeObserverCount: counts.observers,
coreScopeObservationCount: counts.observations,
)
: m;
});
notifyListeners();
}
return result;
}
void _updateChannelMessageById(

@ -891,6 +891,7 @@
},
"channel_coreScopeRefresh": "Refresh CoreScope observers",
"channel_coreScopeRefreshFailed": "CoreScope unreachable",
"channel_coreScopeNotFound": "Not seen by CoreScope yet",
"channel_coreScopeCounts": "{observers, plural, =1{1 observer} other{{observers} observers}} · {observations} observations",
"@channel_coreScopeCounts": {
"placeholders": {

@ -3130,6 +3130,12 @@ abstract class AppLocalizations {
/// **'CoreScope unreachable'**
String get channel_coreScopeRefreshFailed;
/// No description provided for @channel_coreScopeNotFound.
///
/// In en, this message translates to:
/// **'Not seen by CoreScope yet'**
String get channel_coreScopeNotFound;
/// No description provided for @channel_coreScopeCounts.
///
/// In en, this message translates to:

@ -1734,6 +1734,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1732,6 +1732,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1703,6 +1703,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1729,6 +1729,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1736,6 +1736,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1741,6 +1741,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1731,6 +1731,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1666,6 +1666,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1662,6 +1662,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1719,6 +1719,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1744,6 +1744,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1728,6 +1728,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1732,6 +1732,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1721,6 +1721,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1717,6 +1717,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1712,6 +1712,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1727,6 +1727,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -1641,6 +1641,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channel_coreScopeRefreshFailed => 'CoreScope unreachable';
@override
String get channel_coreScopeNotFound => 'Not seen by CoreScope yet';
@override
String channel_coreScopeCounts(int observers, int observations) {
String _temp0 = intl.Intl.pluralLogic(

@ -27,6 +27,7 @@ import '../models/contact.dart';
import '../models/translation_support.dart';
import '../models/app_settings.dart';
import '../services/app_settings_service.dart';
import '../services/corescope_service.dart';
import '../widgets/channel_notify_mode.dart';
import '../services/block_service.dart';
import '../services/chat_text_scale_service.dart';
@ -1920,17 +1921,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (hash == null) return;
final connector = context.read<MeshCoreConnector>();
final l10n = context.l10n;
final counts = await connector.refreshCoreScopeCounts(
final result = await connector.refreshCoreScopeCounts(
message.channelIndex ?? _currentChannel.index,
message.messageId,
hash,
);
if (!mounted) return;
_showCoreScopeBanner(
counts == null
? l10n.channel_coreScopeRefreshFailed
: l10n.channel_coreScopeCounts(counts.observers, counts.observations),
);
final text = switch (result.status) {
CoreScopeStatus.found => l10n.channel_coreScopeCounts(
result.counts!.observers,
result.counts!.observations,
),
CoreScopeStatus.notFound => l10n.channel_coreScopeNotFound,
CoreScopeStatus.unreachable => l10n.channel_coreScopeRefreshFailed,
};
_showCoreScopeBanner(text);
}
/// Top banner (out of the way of the composer), auto-dismissed after a few

@ -17,6 +17,25 @@ class CoreScopeCounts {
final int observations;
}
/// Outcome of a CoreScope query, so the UI can tell "no record of this packet
/// yet" apart from "couldn't reach CoreScope" (#571).
enum CoreScopeStatus { found, notFound, unreachable }
class CoreScopeResult {
const CoreScopeResult(this.status, [this.counts]);
final CoreScopeStatus status;
final CoreScopeCounts? counts;
static const CoreScopeResult notFound = CoreScopeResult(
CoreScopeStatus.notFound,
);
static const CoreScopeResult unreachable = CoreScopeResult(
CoreScopeStatus.unreachable,
);
factory CoreScopeResult.found(CoreScopeCounts counts) =>
CoreScopeResult(CoreScopeStatus.found, counts);
}
/// 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
@ -35,10 +54,10 @@ class CoreScopeService {
final Duration timeout;
/// Distinct observers and total observations for [packetHash] (16 lowercase
/// hex chars). Returns null on any error, or when CoreScope has no record of
/// the hash yet.
Future<CoreScopeCounts?> fetchCounts(String packetHash) async {
if (packetHash.isEmpty) return null;
/// hex chars). `notFound` = CoreScope reachable but has no record of the hash
/// yet; `unreachable` = network/HTTP/parse error. Never throws.
Future<CoreScopeResult> fetchCounts(String packetHash) async {
if (packetHash.isEmpty) return CoreScopeResult.unreachable;
final uri = Uri(
scheme: useTls ? 'https' : 'http',
host: host,
@ -57,19 +76,20 @@ class CoreScopeService {
'HTTP ${resp.statusCode} for hash $packetHash',
tag: 'CoreScope',
);
return null;
return CoreScopeResult.unreachable;
}
final body = jsonDecode(resp.body);
if (body is! Map) return null;
if (body is! Map) return CoreScopeResult.unreachable;
final packets = body['packets'];
if (packets is! List || packets.isEmpty) {
if (packets is! List) return CoreScopeResult.unreachable;
if (packets.isEmpty) {
appLogger.info('no record yet for $packetHash', tag: 'CoreScope');
return null;
return CoreScopeResult.notFound;
}
final first = packets.first;
if (first is! Map) return null;
if (first is! Map) return CoreScopeResult.unreachable;
final observers = first['observer_count'];
if (observers is! num) return null;
if (observers is! num) return CoreScopeResult.unreachable;
final observations = first['observation_count'];
final counts = CoreScopeCounts(
observers: observers.toInt(),
@ -82,10 +102,10 @@ class CoreScopeService {
'for $packetHash',
tag: 'CoreScope',
);
return counts;
return CoreScopeResult.found(counts);
} catch (e) {
appLogger.warn('Query failed for $packetHash: $e', tag: 'CoreScope');
return null;
return CoreScopeResult.unreachable;
}
}

@ -5,87 +5,96 @@ import 'package:meshcore_open/services/corescope_service.dart';
void main() {
group('CoreScopeService.fetchCounts', () {
test(
'parses observer + observation counts from a grouped 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":15,'
'"observation_count":34}],"total":1}',
200,
);
}),
);
test('found: parses observer + observation counts', () 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":15,'
'"observation_count":34}],"total":1}',
200,
);
}),
);
final counts = await service.fetchCounts('abc123');
expect(counts, isNotNull);
expect(counts!.observers, 15);
expect(counts.observations, 34);
expect(captured.host, 'map.okimesh.org');
expect(captured.path, '/api/packets');
expect(captured.queryParameters['hash'], 'abc123');
expect(captured.queryParameters['groupByHash'], 'true');
},
);
final result = await service.fetchCounts('abc123');
expect(result.status, CoreScopeStatus.found);
expect(result.counts!.observers, 15);
expect(result.counts!.observations, 34);
expect(captured.host, 'map.okimesh.org');
expect(captured.path, '/api/packets');
expect(captured.queryParameters['hash'], 'abc123');
expect(captured.queryParameters['groupByHash'], 'true');
});
test('falls back to observers when observation_count is absent', () async {
test('found: observations falls back to observers when absent', () async {
final service = CoreScopeService(
client: MockClient(
(_) async => http.Response('{"packets":[{"observer_count":9}]}', 200),
),
);
final counts = await service.fetchCounts('abc123');
expect(counts!.observers, 9);
expect(counts.observations, 9);
final result = await service.fetchCounts('abc123');
expect(result.status, CoreScopeStatus.found);
expect(result.counts!.observers, 9);
expect(result.counts!.observations, 9);
});
test('returns null when CoreScope has no record (empty packets)', () async {
test('notFound: reachable but empty packets (the #571 case)', () async {
final service = CoreScopeService(
client: MockClient(
(_) async => http.Response('{"packets":[],"total":0}', 200),
),
);
expect(await service.fetchCounts('deadbeef'), isNull);
final result = await service.fetchCounts('deadbeef');
expect(result.status, CoreScopeStatus.notFound);
expect(result.counts, isNull);
});
test('returns null on non-200', () async {
test('unreachable on non-200', () async {
final service = CoreScopeService(
client: MockClient((_) async => http.Response('nope', 503)),
);
expect(await service.fetchCounts('abc123'), isNull);
expect(
(await service.fetchCounts('abc123')).status,
CoreScopeStatus.unreachable,
);
});
test('returns null on malformed body', () async {
test('unreachable on malformed body', () async {
final service = CoreScopeService(
client: MockClient((_) async => http.Response('not json', 200)),
);
expect(await service.fetchCounts('abc123'), isNull);
expect(
(await service.fetchCounts('abc123')).status,
CoreScopeStatus.unreachable,
);
});
test('returns null (never throws) on transport failure', () async {
test('unreachable (never throws) on transport failure', () async {
final service = CoreScopeService(
client: MockClient((_) async => throw Exception('offline')),
);
expect(await service.fetchCounts('abc123'), isNull);
expect(
(await service.fetchCounts('abc123')).status,
CoreScopeStatus.unreachable,
);
});
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.fetchCounts(''), isNull);
expect(called, isFalse);
},
);
test('empty hash: unreachable without hitting the network', () async {
var called = false;
final service = CoreScopeService(
client: MockClient((_) async {
called = true;
return http.Response('{}', 200);
}),
);
expect(
(await service.fetchCounts('')).status,
CoreScopeStatus.unreachable,
);
expect(called, isFalse);
});
});
}

@ -59,6 +59,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -205,6 +206,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -351,6 +353,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -497,6 +500,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -643,6 +647,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -789,6 +794,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -935,6 +941,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1081,6 +1088,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1227,6 +1235,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1373,6 +1382,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1519,6 +1529,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1665,6 +1676,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1811,6 +1823,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -1957,6 +1970,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -2103,6 +2117,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -2249,6 +2264,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",
@ -2395,6 +2411,7 @@
"channel_coreScopeTooltip",
"channel_coreScopeRefresh",
"channel_coreScopeRefreshFailed",
"channel_coreScopeNotFound",
"channel_coreScopeCounts",
"channel_senderNameUnknown",
"channel_senderNameUnknownHint",

Loading…
Cancel
Save

Powered by TurnKey Linux.