feat(#285): ingest RX time logging + persisted rxTime (arrival vs claimed)
Every received DM/channel message now logs arrival wall-clock vs the sender-claimed payload timestamp (delta + TIME ANOMALY marker beyond 24h), routed directly to AppDebugLogService so the rotating on-disk trail is unconditional. Models persist a nullable rxTime set at every live ingest construction site; legacy stored records stay null. Diagnostic child of #280. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>fix/285-ingest-timelog
parent
630886eac0
commit
094241218d
@ -0,0 +1,46 @@
|
|||||||
|
/// Ingest time-anomaly detection (#285, child of #280).
|
||||||
|
///
|
||||||
|
/// A received message carries the SENDER's claimed timestamp in its payload;
|
||||||
|
/// nothing guarantees that clock is sane. These helpers compare the claimed
|
||||||
|
/// time against the local arrival time so the ingest log can prove, after the
|
||||||
|
/// fact, whether an odd date came in on the wire.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Claimed-vs-arrival gap beyond which a received message is logged as a
|
||||||
|
/// time anomaly. Generous enough for mesh store-and-forward delays and
|
||||||
|
/// timezone-free radio clocks; months-off RTCs blow well past it.
|
||||||
|
const Duration rxTimeAnomalyThreshold = Duration(hours: 24);
|
||||||
|
|
||||||
|
/// True when the sender-claimed [claimed] time is further than [threshold]
|
||||||
|
/// from the local [arrival] time in either direction.
|
||||||
|
bool isRxTimeAnomaly(
|
||||||
|
DateTime arrival,
|
||||||
|
DateTime claimed, {
|
||||||
|
Duration threshold = rxTimeAnomalyThreshold,
|
||||||
|
}) {
|
||||||
|
return claimed.difference(arrival).abs() > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact human delta of claimed relative to arrival, e.g. `-64d5h` (claimed
|
||||||
|
/// 64 days in the past) or `+3m` (claimed 3 minutes ahead). `0s` when equal.
|
||||||
|
String formatRxTimeDelta(DateTime arrival, DateTime claimed) {
|
||||||
|
final delta = claimed.difference(arrival);
|
||||||
|
if (delta == Duration.zero) return '0s';
|
||||||
|
final sign = delta.isNegative ? '-' : '+';
|
||||||
|
var d = delta.abs();
|
||||||
|
final parts = <String>[];
|
||||||
|
if (d.inDays > 0) {
|
||||||
|
parts.add('${d.inDays}d');
|
||||||
|
d -= Duration(days: d.inDays);
|
||||||
|
}
|
||||||
|
if (d.inHours > 0) {
|
||||||
|
parts.add('${d.inHours}h');
|
||||||
|
d -= Duration(hours: d.inHours);
|
||||||
|
}
|
||||||
|
if (parts.length < 2 && d.inMinutes > 0) {
|
||||||
|
parts.add('${d.inMinutes}m');
|
||||||
|
d -= Duration(minutes: d.inMinutes);
|
||||||
|
}
|
||||||
|
if (parts.isEmpty) parts.add('${d.inSeconds}s');
|
||||||
|
return '$sign${parts.take(2).join()}';
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/helpers/time_anomaly.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final arrival = DateTime(2026, 7, 18, 22, 47, 3);
|
||||||
|
|
||||||
|
group('isRxTimeAnomaly (#285)', () {
|
||||||
|
test('equal times are not anomalous', () {
|
||||||
|
expect(isRxTimeAnomaly(arrival, arrival), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('within 24h either direction is not anomalous', () {
|
||||||
|
expect(
|
||||||
|
isRxTimeAnomaly(arrival, arrival.subtract(const Duration(hours: 23))),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isRxTimeAnomaly(arrival, arrival.add(const Duration(hours: 23))),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('beyond 24h either direction is anomalous', () {
|
||||||
|
expect(
|
||||||
|
isRxTimeAnomaly(arrival, arrival.subtract(const Duration(hours: 25))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isRxTimeAnomaly(arrival, arrival.add(const Duration(hours: 25))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the #280 case (claimed 64 days in the past) is anomalous', () {
|
||||||
|
final claimed = arrival.subtract(const Duration(days: 64, hours: 5));
|
||||||
|
expect(isRxTimeAnomaly(arrival, claimed), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom threshold is honored', () {
|
||||||
|
expect(
|
||||||
|
isRxTimeAnomaly(
|
||||||
|
arrival,
|
||||||
|
arrival.subtract(const Duration(minutes: 10)),
|
||||||
|
threshold: const Duration(minutes: 5),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('formatRxTimeDelta (#285)', () {
|
||||||
|
test('zero delta', () {
|
||||||
|
expect(formatRxTimeDelta(arrival, arrival), '0s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('claimed in the past is negative, days+hours', () {
|
||||||
|
final claimed = arrival.subtract(const Duration(days: 64, hours: 5));
|
||||||
|
expect(formatRxTimeDelta(arrival, claimed), '-64d5h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('claimed ahead is positive, minutes only', () {
|
||||||
|
final claimed = arrival.add(const Duration(minutes: 3));
|
||||||
|
expect(formatRxTimeDelta(arrival, claimed), '+3m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub-minute deltas fall back to seconds', () {
|
||||||
|
final claimed = arrival.subtract(const Duration(seconds: 45));
|
||||||
|
expect(formatRxTimeDelta(arrival, claimed), '-45s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact whole days omit smaller units', () {
|
||||||
|
final claimed = arrival.subtract(const Duration(days: 2));
|
||||||
|
expect(formatRxTimeDelta(arrival, claimed), '-2d');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps at two units (no minute tail after days+hours)', () {
|
||||||
|
final claimed = arrival.subtract(
|
||||||
|
const Duration(days: 1, hours: 2, minutes: 30),
|
||||||
|
);
|
||||||
|
expect(formatRxTimeDelta(arrival, claimed), '-1d2h');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/models/channel_message.dart';
|
||||||
|
import 'package:meshcore_open/models/message.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final rx = DateTime(2026, 7, 18, 22, 47, 3);
|
||||||
|
final claimed = DateTime(2026, 5, 15, 17, 38, 12);
|
||||||
|
|
||||||
|
group('Message.rxTime (#285)', () {
|
||||||
|
Message build({DateTime? rxTime}) => Message(
|
||||||
|
senderKey: Uint8List(32),
|
||||||
|
text: 'hello',
|
||||||
|
timestamp: claimed,
|
||||||
|
isOutgoing: false,
|
||||||
|
rxTime: rxTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('defaults to null (never fabricated)', () {
|
||||||
|
expect(build().rxTime, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith sets rxTime', () {
|
||||||
|
expect(build().copyWith(rxTime: rx).rxTime, rx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith of unrelated fields preserves rxTime', () {
|
||||||
|
final msg = build(rxTime: rx).copyWith(status: MessageStatus.delivered);
|
||||||
|
expect(msg.rxTime, rx);
|
||||||
|
expect(msg.timestamp, claimed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChannelMessage.rxTime (#285)', () {
|
||||||
|
ChannelMessage build({DateTime? rxTime}) => ChannelMessage(
|
||||||
|
senderName: 'kd8zsp_tdeck',
|
||||||
|
text: 'hello',
|
||||||
|
timestamp: claimed,
|
||||||
|
isOutgoing: false,
|
||||||
|
rxTime: rxTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('defaults to null (never fabricated)', () {
|
||||||
|
expect(build().rxTime, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith sets rxTime', () {
|
||||||
|
expect(build().copyWith(rxTime: rx).rxTime, rx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copyWith(packetHash:) — the live ingest path — preserves rxTime', () {
|
||||||
|
final msg = build(rxTime: rx).copyWith(packetHash: 'abc123');
|
||||||
|
expect(msg.rxTime, rx);
|
||||||
|
expect(msg.timestamp, claimed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in new issue