diff --git a/assets/locales/en.po b/assets/locales/en.po index 909b56f614..a4da04ea82 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -2054,3 +2054,43 @@ msgstr "Tap Edit, then Add Widget." msgid "widget_step_3" msgstr "Search for Lantern, choose a size, and tap add widget." + +# Advanced stats: low-emphasis link at the bottom of the Unbounded status +# card and the title of the bottom sheet it opens. "Data relayed" counts +# bytes this device forwarded for the people it helps over Unbounded. +msgid "advanced_stats" +msgstr "Advanced stats" + +msgid "advanced_stats_current_speed" +msgstr "Current speed" + +msgid "advanced_stats_session" +msgstr "Data relayed this session" + +msgid "advanced_stats_all_time" +msgstr "Data relayed all time" + +# Legend for the ↓ / ↑ arrows next to the figures above. +msgid "advanced_stats_direction_legend" +msgstr "↓ sent to the people you help · ↑ received from them" + +msgid "advanced_stats_countries" +msgstr "Countries you've helped" + +msgid "advanced_stats_no_countries" +msgstr "No one helped yet. Countries will appear here once people connect through you." + +# Country row for peers whose location could not be determined. +msgid "advanced_stats_unknown_country" +msgstr "Unknown" + +msgid "advanced_stats_one_person" +msgstr "1 person" + +# %s is a number greater than one. +msgid "advanced_stats_people" +msgstr "%s people" + +# Shown instead of data figures while sharing via Share My Connection. +msgid "advanced_stats_smc_note" +msgstr "Data stats are available only when sharing via Unbounded." diff --git a/lib/core/extensions/int.dart b/lib/core/extensions/int.dart index 6813c4446e..dfe6e28a6a 100644 --- a/lib/core/extensions/int.dart +++ b/lib/core/extensions/int.dart @@ -1,4 +1,21 @@ extension ByteConversion on int { /// Converts bytes to megabytes (MB) double get toMB => this / 1e6; + + /// Human-readable size in SI (powers of 1000) units, e.g. "1.2 GB". + /// One decimal below 10 units, none above. + String get siBytes { + const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; + if (this < 1000) return '${this < 0 ? 0 : this} B'; + var value = toDouble(); + var unit = 0; + while (value >= 999.5 && unit < units.length - 1) { + value /= 1000; + unit++; + } + final text = value < 9.95 + ? value.toStringAsFixed(1) + : value.toStringAsFixed(0); + return '$text ${units[unit]}'; + } } diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 60fcca2529..6003c7a78f 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -1,3 +1,5 @@ +import 'package:lantern/core/models/relay_stats.dart'; + class AppSetting { final String locale; final String themeMode; @@ -22,6 +24,17 @@ class AppSetting { // Figma. ShareNotifier seeds totalCount from this on build, and // writes back each time the count increments. final int unboundedTotalHelped; + // Lifetime Unbounded bytes relayed (down: to the people helped, up: from + // them) and the per-country breakdown behind Advanced stats. Written by + // ShareNotifier on a throttle, not on every snapshot. + final int unboundedBytesDown; + final int unboundedBytesUp; + final Map unboundedCountryStats; + // The backend epoch and byte counters already folded into the totals + // above, so a restarted app resumes counting where it left off. + final String unboundedRelayEpoch; + final int unboundedRelayMarkDown; + final int unboundedRelayMarkUp; const AppSetting({ this.themeMode = 'system', @@ -37,6 +50,12 @@ class AppSetting { this.unboundedHidden = false, this.unboundedWelcomeSeen = false, this.unboundedTotalHelped = 0, + this.unboundedBytesDown = 0, + this.unboundedBytesUp = 0, + this.unboundedCountryStats = const {}, + this.unboundedRelayEpoch = '', + this.unboundedRelayMarkDown = 0, + this.unboundedRelayMarkUp = 0, }); /// Whether the app points at the staging backend. The notifier writes @@ -57,6 +76,12 @@ class AppSetting { bool? unboundedHidden, bool? unboundedWelcomeSeen, int? unboundedTotalHelped, + int? unboundedBytesDown, + int? unboundedBytesUp, + Map? unboundedCountryStats, + String? unboundedRelayEpoch, + int? unboundedRelayMarkDown, + int? unboundedRelayMarkUp, }) { return AppSetting( locale: newLocale ?? locale, @@ -73,6 +98,14 @@ class AppSetting { unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, unboundedTotalHelped: unboundedTotalHelped ?? this.unboundedTotalHelped, + unboundedBytesDown: unboundedBytesDown ?? this.unboundedBytesDown, + unboundedBytesUp: unboundedBytesUp ?? this.unboundedBytesUp, + unboundedCountryStats: + unboundedCountryStats ?? this.unboundedCountryStats, + unboundedRelayEpoch: unboundedRelayEpoch ?? this.unboundedRelayEpoch, + unboundedRelayMarkDown: + unboundedRelayMarkDown ?? this.unboundedRelayMarkDown, + unboundedRelayMarkUp: unboundedRelayMarkUp ?? this.unboundedRelayMarkUp, ); } @@ -90,6 +123,13 @@ class AppSetting { 'unboundedHidden': unboundedHidden, 'unboundedWelcomeSeen': unboundedWelcomeSeen, 'unboundedTotalHelped': unboundedTotalHelped, + 'unboundedBytesDown': unboundedBytesDown, + 'unboundedBytesUp': unboundedBytesUp, + 'unboundedCountryStats': + RelayCountryStats.mapToJson(unboundedCountryStats), + 'unboundedRelayEpoch': unboundedRelayEpoch, + 'unboundedRelayMarkDown': unboundedRelayMarkDown, + 'unboundedRelayMarkUp': unboundedRelayMarkUp, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -110,5 +150,15 @@ class AppSetting { unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, unboundedTotalHelped: (json['unboundedTotalHelped'] as num?)?.toInt() ?? 0, + unboundedBytesDown: + (json['unboundedBytesDown'] as num?)?.toInt() ?? 0, + unboundedBytesUp: (json['unboundedBytesUp'] as num?)?.toInt() ?? 0, + unboundedCountryStats: + RelayCountryStats.mapFromJson(json['unboundedCountryStats']), + unboundedRelayEpoch: (json['unboundedRelayEpoch'] ?? '').toString(), + unboundedRelayMarkDown: + (json['unboundedRelayMarkDown'] as num?)?.toInt() ?? 0, + unboundedRelayMarkUp: + (json['unboundedRelayMarkUp'] as num?)?.toInt() ?? 0, ); } diff --git a/lib/core/models/relay_stats.dart b/lib/core/models/relay_stats.dart new file mode 100644 index 0000000000..ab86c09432 --- /dev/null +++ b/lib/core/models/relay_stats.dart @@ -0,0 +1,273 @@ +/// Lifetime bytes relayed to and from people in one country, plus how many +/// people from there arrived. [code] is the ISO country code, or +/// [unknownCode] for peers whose country could not be resolved. +class RelayCountryStats { + static const unknownCode = ''; + + final String code; + final String name; + final int down; + final int up; + final int people; + + const RelayCountryStats({ + required this.code, + this.name = '', + this.down = 0, + this.up = 0, + this.people = 0, + }); + + bool get isUnknown => code == unknownCode; + int get bytes => down + up; + + RelayCountryStats add({ + String? name, + int down = 0, + int up = 0, + int people = 0, + }) => RelayCountryStats( + code: code, + name: (name == null || name.isEmpty) ? this.name : name, + down: this.down + down, + up: this.up + up, + people: this.people + people, + ); + + Map toJson() => { + 'name': name, + 'down': down, + 'up': up, + 'people': people, + }; + + factory RelayCountryStats.fromJson(String code, Map json) => + RelayCountryStats( + code: code, + name: (json['name'] ?? '').toString(), + down: (json['down'] as num?)?.toInt() ?? 0, + up: (json['up'] as num?)?.toInt() ?? 0, + people: (json['people'] as num?)?.toInt() ?? 0, + ); + + static Map mapFromJson(Object? json) { + if (json is! Map) return const {}; + return { + for (final e in json.entries) + if (e.value is Map) + e.key.toString(): RelayCountryStats.fromJson( + e.key.toString(), + Map.from(e.value as Map), + ), + }; + } + + static Map mapToJson(Map map) => { + for (final e in map.entries) e.key: e.value.toJson(), + }; +} + +/// Unbounded data-relay figures shown under Advanced stats. Down is data sent +/// to the people helped, up is data received from them. +class RelayStats { + /// Whether the backend reports byte counters at all; older radiance builds + /// send snapshots without them. + final bool available; + final int sessionDown; + final int sessionUp; + final int lifetimeDown; + final int lifetimeUp; + + /// Smoothed bytes per second. + final double speedDown; + final double speedUp; + final Map countries; + + const RelayStats({ + this.available = false, + this.sessionDown = 0, + this.sessionUp = 0, + this.lifetimeDown = 0, + this.lifetimeUp = 0, + this.speedDown = 0, + this.speedUp = 0, + this.countries = const {}, + }); + + int get sessionBytes => sessionDown + sessionUp; + int get lifetimeBytes => lifetimeDown + lifetimeUp; + + /// Countries ordered by data relayed, then by people helped. + List topCountries([int limit = 5]) { + final sorted = countries.values.toList() + ..sort((a, b) { + final byBytes = b.bytes.compareTo(a.bytes); + return byBytes != 0 ? byBytes : b.people.compareTo(a.people); + }); + return sorted.take(limit).toList(); + } + + /// Keeps lifetime totals and drops everything tied to the previous session. + RelayStats newSession() => RelayStats( + available: available, + lifetimeDown: lifetimeDown, + lifetimeUp: lifetimeUp, + countries: countries, + ); + + RelayStats copyWith({ + bool? available, + int? sessionDown, + int? sessionUp, + int? lifetimeDown, + int? lifetimeUp, + double? speedDown, + double? speedUp, + Map? countries, + }) => RelayStats( + available: available ?? this.available, + sessionDown: sessionDown ?? this.sessionDown, + sessionUp: sessionUp ?? this.sessionUp, + lifetimeDown: lifetimeDown ?? this.lifetimeDown, + lifetimeUp: lifetimeUp ?? this.lifetimeUp, + speedDown: speedDown ?? this.speedDown, + speedUp: speedUp ?? this.speedUp, + countries: countries ?? this.countries, + ); +} + +typedef ByteCounts = ({int down, int up}); + +/// What changed between two Unbounded snapshots. +class RelayDelta { + final int down; + final int up; + final Map peers; + + /// Peer IPs that showed up since the previous snapshot. + final Set arrivals; + + /// True when the counters restarted, so [down]/[up] are not a rate over + /// the snapshot interval. + final bool reset; + + const RelayDelta({ + this.down = 0, + this.up = 0, + this.peers = const {}, + this.arrivals = const {}, + this.reset = false, + }); + + static const none = RelayDelta(); +} + +/// Turns the cumulative per-epoch byte counters of successive Unbounded +/// snapshots into deltas. +/// +/// Without a [restore]d watermark the first snapshot only sets the baseline, +/// since its counters may already have been folded into persisted totals. A +/// new epoch means the backend restarted, so its counters count from zero. A +/// down/up pair where either counter goes backwards within an epoch is +/// treated the same way, as both restart together. Peers that drop out of +/// `peerBytes` are forgotten, so a returning peer counts from zero. +class RelayCounterTracker { + String? _epoch; + int _down = 0; + int _up = 0; + Map _peers = {}; + Set _present = {}; + bool _restored = false; + + /// The epoch and total counters already folded into the returned deltas, + /// for persisting alongside the totals they were folded into. + ({String epoch, int down, int up})? get watermark => + _epoch == null ? null : (epoch: _epoch!, down: _down, up: _up); + + /// Resumes from a persisted [watermark] so the first snapshot of the same + /// epoch counts what was relayed while the app was not watching. Peer + /// counters are not persisted, so that snapshot only baselines them. + void restore({required String epoch, required int down, required int up}) { + if (_epoch != null || epoch.isEmpty) return; + _epoch = epoch; + _down = down; + _up = up; + _restored = true; + } + + static ByteCounts _diff(ByteCounts? prev, ByteCounts cur) => + (prev == null || cur.down < prev.down || cur.up < prev.up) + ? cur + : (down: cur.down - prev.down, up: cur.up - prev.up); + + RelayDelta apply({ + required String epoch, + required int down, + required int up, + required Map peers, + required Set present, + }) { + final allPresent = {...present, ...peers.keys}; + if (_restored) { + _restored = false; + if (_epoch == epoch) { + final reset = down < _down || up < _up; + final total = _diff((down: _down, up: _up), (down: down, up: up)); + _down = down; + _up = up; + _peers = Map.of(peers); + _present = allPresent; + return RelayDelta(down: total.down, up: total.up, reset: reset); + } + } + if (_epoch == null || _epoch != epoch) { + final first = _epoch == null; + _epoch = epoch; + _down = down; + _up = up; + _peers = Map.of(peers); + _present = allPresent; + if (first) return RelayDelta.none; + return RelayDelta(down: down, up: up, peers: Map.of(peers), reset: true); + } + + final reset = down < _down || up < _up; + final total = _diff((down: _down, up: _up), (down: down, up: up)); + final peerDeltas = {}; + for (final entry in peers.entries) { + final d = _diff(_peers[entry.key], entry.value); + if (d.down > 0 || d.up > 0) peerDeltas[entry.key] = d; + } + final arrivals = allPresent.difference(_present); + _down = down; + _up = up; + _peers = Map.of(peers); + _present = allPresent; + return RelayDelta( + down: total.down, + up: total.up, + peers: peerDeltas, + arrivals: arrivals, + reset: reset, + ); + } + + /// Parses `peerBytes` from a snapshot. Entries without an IP are skipped + /// and repeated IPs are summed. + static Map parsePeerBytes(Object? raw) { + if (raw is! List) return const {}; + final out = {}; + for (final item in raw) { + if (item is! Map) continue; + final ip = (item['ip'] as String?) ?? ''; + if (ip.isEmpty) continue; + final d = (item['down'] as num?)?.toInt() ?? 0; + final u = (item['up'] as num?)?.toInt() ?? 0; + final prev = out[ip]; + out[ip] = prev == null + ? (down: d, up: u) + : (down: prev.down + d, up: prev.up + u); + } + return out; + } +} diff --git a/lib/core/models/share_state.dart b/lib/core/models/share_state.dart index aab43cdc29..9c70bbb8e1 100644 --- a/lib/core/models/share_state.dart +++ b/lib/core/models/share_state.dart @@ -1,3 +1,5 @@ +import 'package:lantern/core/models/relay_stats.dart'; + /// Which protocol the user is sharing through. /// /// off: not sharing, or probe in flight. unbounded: broflake/WebRTC relay. @@ -41,6 +43,7 @@ class ShareState { /// SmC only; Unbounded reports readiness via [unboundedRunning]. final SharePhase phase; final String? errorMessage; + final RelayStats relay; const ShareState({ this.active = false, @@ -51,6 +54,7 @@ class ShareState { this.totalCount = 0, this.phase = SharePhase.idle, this.errorMessage, + this.relay = const RelayStats(), }); ShareState copyWith({ @@ -63,6 +67,7 @@ class ShareState { SharePhase? phase, // Sentinel default so callers can clear the message by passing null. Object? errorMessage = _unsetErrorMessage, + RelayStats? relay, }) => ShareState( active: active ?? this.active, @@ -75,6 +80,7 @@ class ShareState { errorMessage: identical(errorMessage, _unsetErrorMessage) ? this.errorMessage : errorMessage as String?, + relay: relay ?? this.relay, ); } diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart index 38616bc444..3b81a2c989 100644 --- a/lib/core/services/geo_lookup_service.dart +++ b/lib/core/services/geo_lookup_service.dart @@ -42,9 +42,14 @@ class GeoLookupService { // don't change on human timescales, and the alternative TTL // bookkeeping adds complexity without changing the result. static final Map _peerCache = {}; + // Lookups still in flight, so concurrent callers for one IP share a request. + static final Map> _peerPending = {}; /// Test-only: drop the cache. Production code does not call this. - static void resetCacheForTest() => _peerCache.clear(); + static void resetCacheForTest() { + _peerCache.clear(); + _peerPending.clear(); + } // ISO country code → approximate centre coordinates. Used as a fallback // when the geo service doesn't return city-level coords. @@ -231,12 +236,18 @@ class GeoLookupService { /// same MaxMind-backed service used for the caller's own location) rather /// than a third party. It's an address the local host already observes as a /// connection source, and the service doesn't tie lookups to the caller. - static Future peerLookup(String ip) async { + static Future peerLookup(String ip) { // Cache hit: short-circuit before any network I/O. Also caches // PeerGeo.unknown so a previously-failed lookup doesn't retry on // every subsequent probe from the same IP. final cached = _peerCache[ip]; - if (cached != null) return cached; + if (cached != null) return Future.value(cached); + return _peerPending[ip] ??= _fetchPeer(ip).whenComplete(() { + _peerPending.remove(ip); + }); + } + + static Future _fetchPeer(String ip) async { PeerGeo result = PeerGeo.unknown; try { final response = await http diff --git a/lib/features/action_mode/action_mode.dart b/lib/features/action_mode/action_mode.dart index afa971fc73..babb82f96d 100644 --- a/lib/features/action_mode/action_mode.dart +++ b/lib/features/action_mode/action_mode.dart @@ -8,6 +8,7 @@ import 'package:lantern/core/models/share_state.dart'; import 'package:lantern/core/widgets/info_row.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/action_mode/action_mode_widgets.dart'; +import 'package:lantern/features/action_mode/advanced_stats_sheet.dart'; import 'package:lantern/features/action_mode/peer_status_pill.dart'; import 'package:lantern/features/action_mode/auto_enable_mode.dart'; import 'package:lantern/features/action_mode/provider/share_notifier.dart'; @@ -129,6 +130,7 @@ class _StatusCard extends StatelessWidget { activeCount: state.activeCount, totalCount: state.totalCount, onToggle: onToggle, + onAdvancedStats: () => showAdvancedStatsSheet(context), ); } } diff --git a/lib/features/action_mode/action_mode_widgets.dart b/lib/features/action_mode/action_mode_widgets.dart index 7def5f1dbb..1b313bc408 100644 --- a/lib/features/action_mode/action_mode_widgets.dart +++ b/lib/features/action_mode/action_mode_widgets.dart @@ -16,11 +16,13 @@ class ActionModeStatusCard extends StatelessWidget { required this.activeCount, required this.totalCount, required this.onToggle, + this.onAdvancedStats, }); final String status; final bool enabled, ready, busy, hasError; final int activeCount, totalCount; final VoidCallback onToggle; + final VoidCallback? onAdvancedStats; @override Widget build(BuildContext context) { @@ -97,12 +99,49 @@ class ActionModeStatusCard extends StatelessWidget { label: 'smc_stat_total_helped'.i18n, value: totalCount, ), + if (onAdvancedStats != null) ...[ + Divider(height: 1, color: context.borderDefault), + _AdvancedStatsLink(onTap: onAdvancedStats!), + ], ], ), ); } } +class _AdvancedStatsLink extends StatelessWidget { + const _AdvancedStatsLink({required this.onTap}); + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + child: InkWell( + key: const Key('action-mode.advanced-stats'), + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + 'advanced_stats'.i18n, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.textSecondary, + ), + ), + ), + Icon(Icons.chevron_right, size: 20, color: context.textTertiary), + ], + ), + ), + ), + ), + ); +} + class _ImpactRow extends StatelessWidget { const _ImpactRow({ required this.icon, diff --git a/lib/features/action_mode/advanced_stats_sheet.dart b/lib/features/action_mode/advanced_stats_sheet.dart new file mode 100644 index 0000000000..a214c915d0 --- /dev/null +++ b/lib/features/action_mode/advanced_stats_sheet.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/app_text_styles.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/relay_stats.dart'; +import 'package:lantern/core/models/share_state.dart'; +import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/section_label.dart'; +import 'package:lantern/features/action_mode/provider/share_notifier.dart'; + +void showAdvancedStatsSheet(BuildContext context) { + showAppBottomSheet( + context: context, + title: 'advanced_stats'.i18n, + builder: (context, scrollController) => + Expanded(child: AdvancedStatsView(scrollController: scrollController)), + ); +} + +/// Data-relay figures for Unbounded volunteers. Byte counters only exist for +/// Unbounded, so SmC sessions get a note in place of live figures. +class AdvancedStatsView extends ConsumerWidget { + const AdvancedStatsView({super.key, this.scrollController}); + final ScrollController? scrollController; + + static const topCountryCount = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(shareProvider); + final relay = state.relay; + final smc = state.mode == ShareMode.smc; + final live = !smc && relay.available; + final countries = relay.topCountries(topCountryCount); + final divider = Divider(height: 1, color: context.borderDefault); + + return ListView( + key: const Key('advanced-stats.list'), + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + children: [ + if (smc) ...[ + InfoRow( + key: const Key('advanced-stats.smc-note'), + text: 'advanced_stats_smc_note'.i18n, + ), + const SizedBox(height: 16), + ], + AppCard( + child: Column( + children: [ + if (!smc) ...[ + _StatRow( + label: 'advanced_stats_current_speed'.i18n, + value: live + ? _directions( + '${relay.speedDown.round().siBytes}/s', + '${relay.speedUp.round().siBytes}/s', + ) + : '—', + ), + divider, + _StatRow( + label: 'advanced_stats_session'.i18n, + value: live ? relay.sessionBytes.siBytes : '—', + detail: live + ? _directions( + relay.sessionDown.siBytes, + relay.sessionUp.siBytes, + ) + : null, + ), + divider, + ], + _StatRow( + label: 'advanced_stats_all_time'.i18n, + value: relay.lifetimeBytes.siBytes, + detail: relay.lifetimeBytes > 0 + ? _directions( + relay.lifetimeDown.siBytes, + relay.lifetimeUp.siBytes, + ) + : null, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8, left: 4, right: 4), + child: Text( + 'advanced_stats_direction_legend'.i18n, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.textTertiary), + ), + ), + const SizedBox(height: 16), + SectionLabel('advanced_stats_countries'.i18n), + const SizedBox(height: 4), + AppCard( + child: countries.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'advanced_stats_no_countries'.i18n, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.textTertiary, + ), + ), + ) + : Column( + children: [ + for (var i = 0; i < countries.length; i++) ...[ + if (i > 0) divider, + _CountryRow(stats: countries[i]), + ], + ], + ), + ), + ], + ); + } +} + +String _directions(String down, String up) => '↓ $down ↑ $up'; + +class _StatRow extends StatelessWidget { + const _StatRow({required this.label, required this.value, this.detail}); + final String label, value; + final String? detail; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyLarge), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + value, + textAlign: TextAlign.end, + style: AppTextStyles.bodyLargeBold.copyWith( + color: context.textLink, + ), + ), + if (detail != null) + Text( + detail!, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.textTertiary, + ), + ), + ], + ), + ), + ], + ), + ), + ); +} + +class _CountryRow extends StatelessWidget { + const _CountryRow({required this.stats}); + final RelayCountryStats stats; + + @override + Widget build(BuildContext context) { + final name = stats.isUnknown + ? 'advanced_stats_unknown_country'.i18n + : (stats.name.isEmpty ? stats.code : stats.name); + final people = switch (stats.people) { + 0 => null, + 1 => 'advanced_stats_one_person'.i18n, + _ => 'advanced_stats_people'.i18n.fill(['${stats.people}']), + }; + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + SizedBox( + width: 25, + child: stats.isUnknown + ? Icon(Icons.public, size: 20, color: context.textTertiary) + : Flag(countryCode: stats.code), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: Theme.of(context).textTheme.bodyLarge), + Text( + people == null + ? stats.bytes.siBytes + : '${stats.bytes.siBytes} · $people', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/action_mode/provider/share_notifier.dart b/lib/features/action_mode/provider/share_notifier.dart index 0a49690d59..06c57e6608 100644 --- a/lib/features/action_mode/provider/share_notifier.dart +++ b/lib/features/action_mode/provider/share_notifier.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/action_mode_connection_event.dart'; import 'package:lantern/core/models/app_event.dart'; +import 'package:lantern/core/models/relay_stats.dart'; import 'package:lantern/core/models/share_state.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; @@ -62,6 +63,22 @@ class ShareNotifier extends _$ShareNotifier { // so each arc is ref-counted and stays until the peer's last stream closes. final Map _peerArcs = {}; + // ─── Relay stats ─────────────────────────────────────────────────────────── + + @visibleForTesting + Duration relayPersistInterval = const Duration(seconds: 30); + // Weight of the newest sample in the smoothed speed. + static const _speedSmoothing = 0.5; + + final _relayCounters = RelayCounterTracker(); + DateTime? _lastRelaySampleAt; + DateTime? _lastRelayPersistAt; + Timer? _relayPersistTimer; + bool _relayDirty = false; + + @visibleForTesting + DateTime Function() clock = DateTime.now; + final _eventController = StreamController.broadcast(); @@ -81,6 +98,7 @@ class ShareNotifier extends _$ShareNotifier { _snapshotSub?.cancel(); _stopEventSubscription(); _eventController.close(); + _relayPersistTimer?.cancel(); }); final settings = ref.read(appSettingProvider); @@ -93,7 +111,19 @@ class ShareNotifier extends _$ShareNotifier { ref.read(appSettingProvider.notifier).setUnboundedAutoEnable(false), ); } - return ShareState(totalCount: settings.unboundedTotalHelped); + _relayCounters.restore( + epoch: settings.unboundedRelayEpoch, + down: settings.unboundedRelayMarkDown, + up: settings.unboundedRelayMarkUp, + ); + return ShareState( + totalCount: settings.unboundedTotalHelped, + relay: RelayStats( + lifetimeDown: settings.unboundedBytesDown, + lifetimeUp: settings.unboundedBytesUp, + countries: settings.unboundedCountryStats, + ), + ); } /// Toggles sharing. Shows the consent dialog inline when needed, then @@ -239,7 +269,12 @@ class ShareNotifier extends _$ShareNotifier { } Future _start(WidgetRef widgetRef, ShareMode mode) async { - state = ShareState(active: true, mode: mode, totalCount: state.totalCount); + state = ShareState( + active: true, + mode: mode, + totalCount: state.totalCount, + relay: state.relay.newSession(), + ); _startEventSubscription(widgetRef); switch (mode) { case ShareMode.smc: @@ -289,7 +324,8 @@ class ShareNotifier extends _$ShareNotifier { }, (_) { _stopEventSubscription(); - state = ShareState(totalCount: state.totalCount); + state = ShareState(totalCount: state.totalCount, relay: _idleRelay()); + _persistRelay(force: true); }, ); } @@ -305,6 +341,7 @@ class ShareNotifier extends _$ShareNotifier { active: true, mode: ShareMode.unbounded, totalCount: state.totalCount, + relay: state.relay.newSession(), ); final result = await widgetRef .read(lanternServiceProvider) @@ -327,7 +364,9 @@ class ShareNotifier extends _$ShareNotifier { totalCount: state.totalCount, phase: SharePhase.error, errorMessage: message, + relay: _idleRelay(), ); + _persistRelay(force: true); } // ─── Backend events ──────────────────────────────────────────────────────── @@ -337,7 +376,12 @@ class ShareNotifier extends _$ShareNotifier { if (event.eventType == 'unbounded-unavailable') { if (!_changing && state.mode == ShareMode.unbounded) { _clearPeers(); - state = state.copyWith(unboundedRunning: false, activeCount: 0); + state = state.copyWith( + unboundedRunning: false, + activeCount: 0, + relay: _idleRelay(), + ); + _persistRelay(force: true); } return; } @@ -391,7 +435,7 @@ class ShareNotifier extends _$ShareNotifier { } if (phase == SharePhase.idle) { _stopEventSubscription(); - state = ShareState(totalCount: state.totalCount); + state = ShareState(totalCount: state.totalCount, relay: state.relay); return; } state = state.copyWith( @@ -446,7 +490,21 @@ class ShareNotifier extends _$ShareNotifier { /// peers that vanished, adds arcs for new ones, and folds completed /// arrivals into the lifetime total. void _applyUnboundedSnapshot(Map snapshot) { - if (_changing || state.mode == ShareMode.smc) return; + if (_changing) return; + if (state.mode == ShareMode.smc) { + // Unbounded can still flush bytes from a session that stopped just + // before SmC started. Count them without touching SmC's state. + state = state.copyWith( + relay: _applyRelayCounters( + snapshot, + epoch: snapshot['epoch'] as String, + present: const {}, + running: false, + ), + ); + _persistRelay(force: true); + return; + } final enabled = snapshot['enabled'] as bool; final running = snapshot['running'] as bool; final peers = (snapshot['peers'] as List).cast(); @@ -488,10 +546,158 @@ class ShareNotifier extends _$ShareNotifier { unboundedRunning: enabled && running, activeCount: counts.length, totalCount: total, + relay: _applyRelayCounters( + snapshot, + epoch: epoch, + present: counts.keys.toSet(), + running: enabled && running, + ), ); if (total != ref.read(appSettingProvider).unboundedTotalHelped) { ref.read(appSettingProvider.notifier).setUnboundedTotalHelped(total); } + // A stopped backend's final counters may be the last this app sees. + _persistRelay(force: !(enabled && running)); + } + + // ─── Relay stats ─────────────────────────────────────────────────────────── + + /// Folds a snapshot's byte counters into session and lifetime totals and + /// the smoothed speed. Per-country totals follow asynchronously, once each + /// peer's country is resolved. + RelayStats _applyRelayCounters( + Map snapshot, { + required String epoch, + required Set present, + required bool running, + }) { + final available = snapshot.containsKey('bytesDown'); + final peerBytes = {}; + RelayCounterTracker.parsePeerBytes(snapshot['peerBytes']).forEach(( + source, + counts, + ) { + final ip = source.hostAddress; + if (ip.isEmpty) return; + final prev = peerBytes[ip]; + peerBytes[ip] = prev == null + ? counts + : (down: prev.down + counts.down, up: prev.up + counts.up); + }); + final markBefore = _relayCounters.watermark; + final delta = _relayCounters.apply( + epoch: epoch, + down: (snapshot['bytesDown'] as num?)?.toInt() ?? 0, + up: (snapshot['bytesUp'] as num?)?.toInt() ?? 0, + peers: peerBytes, + present: present, + ); + + final now = clock(); + final previousSampleAt = _lastRelaySampleAt; + _lastRelaySampleAt = now; + var relay = state.relay; + var speedDown = relay.speedDown; + var speedUp = relay.speedUp; + if (!running || !available) { + speedDown = 0; + speedUp = 0; + } else if (!delta.reset && previousSampleAt != null) { + final seconds = now.difference(previousSampleAt).inMicroseconds / 1e6; + if (seconds > 0) { + speedDown += (delta.down / seconds - speedDown) * _speedSmoothing; + speedUp += (delta.up / seconds - speedUp) * _speedSmoothing; + } + } + relay = relay.copyWith( + available: available, + sessionDown: relay.sessionDown + delta.down, + sessionUp: relay.sessionUp + delta.up, + lifetimeDown: relay.lifetimeDown + delta.down, + lifetimeUp: relay.lifetimeUp + delta.up, + speedDown: speedDown, + speedUp: speedUp, + ); + // A moved watermark is saved even without bytes to add, so the next app + // run resumes from it instead of taking another baseline. + if (delta.down > 0 || + delta.up > 0 || + _relayCounters.watermark != markBefore) { + _relayDirty = true; + } + + for (final ip in {...delta.peers.keys, ...delta.arrivals}) { + unawaited( + _attributeToCountry( + ip, + delta.peers[ip] ?? (down: 0, up: 0), + arrived: delta.arrivals.contains(ip), + ), + ); + } + return relay; + } + + Future _attributeToCountry( + String ip, + ByteCounts bytes, { + required bool arrived, + }) async { + PeerGeo geo; + try { + geo = await GeoLookupService.peerLookup(ip); + } catch (_) { + geo = PeerGeo.unknown; + } + if (!ref.mounted) return; + final code = geo.countryCode.isEmpty + ? RelayCountryStats.unknownCode + : geo.countryCode; + final countries = Map.of(state.relay.countries); + countries[code] = (countries[code] ?? RelayCountryStats(code: code)).add( + name: geo.countryName, + down: bytes.down, + up: bytes.up, + people: arrived ? 1 : 0, + ); + state = state.copyWith(relay: state.relay.copyWith(countries: countries)); + _relayDirty = true; + _persistRelay(); + } + + RelayStats _idleRelay() => state.relay.copyWith(speedDown: 0, speedUp: 0); + + /// AppSetting writes log and re-serialize every setting, so lifetime relay + /// stats are saved at most every [relayPersistInterval] unless [force]d. + /// A throttled save is deferred, not dropped, so late country lookups are + /// saved even when no further snapshot arrives. + void _persistRelay({bool force = false}) { + if (!_relayDirty) return; + final now = clock(); + final last = _lastRelayPersistAt; + if (!force && last != null) { + final wait = relayPersistInterval - now.difference(last); + if (wait > Duration.zero) { + _relayPersistTimer ??= Timer(wait, () { + _relayPersistTimer = null; + if (ref.mounted) _persistRelay(force: true); + }); + return; + } + } + _relayPersistTimer?.cancel(); + _relayPersistTimer = null; + _relayDirty = false; + _lastRelayPersistAt = now; + final relay = state.relay; + ref + .read(appSettingProvider.notifier) + .setUnboundedRelayStats( + bytesDown: relay.lifetimeDown, + bytesUp: relay.lifetimeUp, + countries: relay.countries, + mark: _relayCounters.watermark, + ); } // ─── Peer arcs ───────────────────────────────────────────────────────────── diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index f4dca8f789..e603c4b1f6 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_setting.dart'; +import 'package:lantern/core/models/relay_stats.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/core/utils/radiance_environment.dart'; @@ -118,6 +119,22 @@ class AppSettingNotifier extends _$AppSettingNotifier { void setUnboundedTotalHelped(int value) => update(state.copyWith(unboundedTotalHelped: value)); + void setUnboundedRelayStats({ + required int bytesDown, + required int bytesUp, + required Map countries, + ({String epoch, int down, int up})? mark, + }) => update( + state.copyWith( + unboundedBytesDown: bytesDown, + unboundedBytesUp: bytesUp, + unboundedCountryStats: countries, + unboundedRelayEpoch: mark?.epoch, + unboundedRelayMarkDown: mark?.down, + unboundedRelayMarkUp: mark?.up, + ), + ); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/test/core/models/relay_stats_test.dart b/test/core/models/relay_stats_test.dart new file mode 100644 index 0000000000..d2a5ff7e07 --- /dev/null +++ b/test/core/models/relay_stats_test.dart @@ -0,0 +1,290 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/extensions/int.dart'; +import 'package:lantern/core/models/app_setting.dart'; +import 'package:lantern/core/models/relay_stats.dart'; + +void main() { + group('RelayCounterTracker', () { + RelayDelta apply( + RelayCounterTracker t, { + String epoch = 'run-1', + int down = 0, + int up = 0, + Map peers = const {}, + Set present = const {}, + }) => t.apply( + epoch: epoch, + down: down, + up: up, + peers: peers, + present: present, + ); + + test('first snapshot is only a baseline', () { + final t = RelayCounterTracker(); + final d = apply( + t, + down: 5000, + up: 700, + peers: {'192.0.2.1': (down: 5000, up: 700)}, + present: {'192.0.2.1'}, + ); + expect(d.down, 0); + expect(d.up, 0); + expect(d.peers, isEmpty); + expect(d.arrivals, isEmpty); + }); + + test('same epoch yields per-total and per-IP deltas', () { + final t = RelayCounterTracker(); + apply(t, down: 100, up: 10, peers: {'a': (down: 100, up: 10)}); + final d = apply( + t, + down: 350, + up: 40, + peers: {'a': (down: 300, up: 30), 'b': (down: 50, up: 0)}, + ); + expect(d.down, 250); + expect(d.up, 30); + expect(d.reset, isFalse); + expect(d.peers['a'], (down: 200, up: 20)); + expect(d.peers['b'], (down: 50, up: 0)); + }); + + test('unchanged peers produce no per-IP delta', () { + final t = RelayCounterTracker(); + apply(t, down: 100, peers: {'a': (down: 100, up: 0)}); + final d = apply(t, down: 100, peers: {'a': (down: 100, up: 0)}); + expect(d.down, 0); + expect(d.peers, isEmpty); + }); + + test('either counter going backwards resets both: delta is current', () { + final t = RelayCounterTracker(); + apply(t, down: 1000, up: 500, peers: {'a': (down: 1000, up: 500)}); + final d = apply(t, down: 40, up: 600, peers: {'a': (down: 40, up: 600)}); + expect(d.down, 40); + expect(d.up, 600); + expect(d.reset, isTrue); + expect(d.peers['a'], (down: 40, up: 600)); + final e = apply(t, down: 50, up: 10, peers: {'a': (down: 50, up: 10)}); + expect((e.down, e.up), (50, 10)); + expect(e.peers['a'], (down: 50, up: 10)); + }); + + test('a restored watermark counts the same epoch from where it left', () { + final t = RelayCounterTracker() + ..restore(epoch: 'run-1', down: 100, up: 10); + final d = apply( + t, + down: 1000, + up: 40, + peers: {'a': (down: 900, up: 30)}, + present: {'a'}, + ); + expect((d.down, d.up, d.reset), (900, 30, false)); + // Peer counters were not persisted, so they only set a baseline. + expect(d.peers, isEmpty); + expect(d.arrivals, isEmpty); + expect(t.watermark, (epoch: 'run-1', down: 1000, up: 40)); + final next = apply( + t, + down: 1100, + up: 40, + peers: {'a': (down: 1000, up: 30)}, + ); + expect(next.down, 100); + expect(next.peers['a'], (down: 100, up: 0)); + }); + + test('a restored watermark from another epoch counts from zero', () { + final t = RelayCounterTracker() + ..restore(epoch: 'run-0', down: 100, up: 10); + final d = apply(t, down: 70, up: 7, peers: {'a': (down: 70, up: 7)}); + expect((d.down, d.up, d.reset), (70, 7, true)); + expect(d.peers['a'], (down: 70, up: 7)); + }); + + test('an empty restored epoch keeps the first snapshot a baseline', () { + final t = RelayCounterTracker()..restore(epoch: '', down: 0, up: 0); + expect(t.watermark, isNull); + expect(apply(t, down: 500).down, 0); + }); + + test('a new epoch counts everything since it started', () { + final t = RelayCounterTracker(); + apply(t, down: 9000, up: 900, peers: {'a': (down: 9000, up: 900)}); + final d = apply( + t, + epoch: 'run-2', + down: 300, + up: 30, + peers: {'b': (down: 300, up: 30)}, + present: {'b'}, + ); + expect(d.down, 300); + expect(d.up, 30); + expect(d.reset, isTrue); + expect(d.peers, {'b': (down: 300, up: 30)}); + // Like the arrivals counter, the first snapshot of a new epoch does + // not count people as arrivals. + expect(d.arrivals, isEmpty); + final next = apply( + t, + epoch: 'run-2', + down: 400, + up: 30, + peers: {'b': (down: 400, up: 30)}, + ); + expect(next.down, 100); + expect(next.peers, {'b': (down: 100, up: 0)}); + }); + + test('a recently disconnected IP keeps diffing while still listed', () { + final t = RelayCounterTracker(); + apply(t, peers: {'a': (down: 100, up: 0)}, present: {'a'}); + // Gone from peers but still in peerBytes: not a new arrival, and the + // final bytes it moved before leaving are attributed. + final d = apply(t, peers: {'a': (down: 180, up: 5)}, present: {}); + expect(d.arrivals, isEmpty); + expect(d.peers['a'], (down: 80, up: 5)); + // Expired from peerBytes, then back: counted from zero and as a new + // arrival. + apply(t); + final back = apply(t, peers: {'a': (down: 30, up: 0)}, present: {'a'}); + expect(back.peers['a'], (down: 30, up: 0)); + expect(back.arrivals, {'a'}); + }); + + test('a peer that came and went between snapshots still arrives', () { + final t = RelayCounterTracker(); + apply(t); + final d = apply(t, down: 64, peers: {'c': (down: 64, up: 0)}); + expect(d.arrivals, {'c'}); + expect(d.peers['c'], (down: 64, up: 0)); + }); + + test('arrivals work without peerBytes (older radiance)', () { + final t = RelayCounterTracker(); + apply(t, present: {'a'}); + final d = apply(t, present: {'a', 'b'}); + expect(d.arrivals, {'b'}); + expect(d.down, 0); + expect(d.peers, isEmpty); + }); + + test('parsePeerBytes tolerates junk and sums repeated IPs', () { + expect(RelayCounterTracker.parsePeerBytes(null), isEmpty); + expect(RelayCounterTracker.parsePeerBytes('nope'), isEmpty); + expect( + RelayCounterTracker.parsePeerBytes([ + {'ip': '192.0.2.1', 'down': 10, 'up': 1}, + {'ip': '192.0.2.1', 'down': 5, 'up': 2}, + {'ip': '', 'down': 99, 'up': 99}, + {'down': 99}, + 'garbage', + {'ip': '192.0.2.2'}, + ]), + {'192.0.2.1': (down: 15, up: 3), '192.0.2.2': (down: 0, up: 0)}, + ); + }); + }); + + group('RelayStats', () { + test('topCountries orders by bytes, then people, and limits', () { + final stats = RelayStats( + countries: { + for (final (code, bytes, people) in [ + ('IR', 500, 1), + ('RU', 900, 2), + ('CN', 500, 4), + ('', 100, 9), + ('MM', 50, 1), + ('BY', 10, 1), + ]) + code: RelayCountryStats(code: code, down: bytes, people: people), + }, + ); + expect(stats.topCountries().map((c) => c.code), [ + 'RU', + 'CN', + 'IR', + '', + 'MM', + ]); + }); + + test('newSession keeps lifetime figures and clears the session', () { + final stats = const RelayStats( + available: true, + sessionDown: 5, + sessionUp: 6, + lifetimeDown: 50, + lifetimeUp: 60, + speedDown: 7, + speedUp: 8, + countries: {'IR': RelayCountryStats(code: 'IR', down: 1)}, + ).newSession(); + expect(stats.sessionBytes, 0); + expect(stats.speedDown, 0); + expect(stats.lifetimeBytes, 110); + expect(stats.countries.keys, ['IR']); + expect(stats.available, isTrue); + }); + }); + + test('AppSetting round-trips relay totals and tolerates their absence', () { + const setting = AppSetting( + unboundedBytesDown: 1234, + unboundedBytesUp: 56, + unboundedCountryStats: { + 'IR': RelayCountryStats( + code: 'IR', + name: 'Iran', + down: 1000, + up: 50, + people: 3, + ), + '': RelayCountryStats(code: '', down: 234, up: 6, people: 1), + }, + ); + final restored = AppSetting.fromJson( + setting + .copyWith( + unboundedRelayEpoch: 'run-1', + unboundedRelayMarkDown: 99, + unboundedRelayMarkUp: 9, + ) + .toJson(), + ); + expect( + ( + restored.unboundedRelayEpoch, + restored.unboundedRelayMarkDown, + restored.unboundedRelayMarkUp, + ), + ('run-1', 99, 9), + ); + expect(restored.unboundedBytesDown, 1234); + expect(restored.unboundedBytesUp, 56); + final iran = restored.unboundedCountryStats['IR']!; + expect((iran.name, iran.down, iran.up, iran.people), ('Iran', 1000, 50, 3)); + expect(restored.unboundedCountryStats['']!.isUnknown, isTrue); + + final legacy = AppSetting.fromJson({'unboundedTotalHelped': 4}); + expect(legacy.unboundedBytesDown, 0); + expect(legacy.unboundedCountryStats, isEmpty); + expect(legacy.unboundedRelayEpoch, isEmpty); + }); + + test('siBytes uses SI units', () { + expect(0.siBytes, '0 B'); + expect(999.siBytes, '999 B'); + expect(1000.siBytes, '1.0 kB'); + expect(1260.siBytes, '1.3 kB'); + expect(12345.siBytes, '12 kB'); + expect(999999.siBytes, '1.0 MB'); + expect(1200000000.siBytes, '1.2 GB'); + expect(3400000000000.siBytes, '3.4 TB'); + }); +} diff --git a/test/core/services/geo_lookup_service_test.dart b/test/core/services/geo_lookup_service_test.dart new file mode 100644 index 0000000000..9b713b4d99 --- /dev/null +++ b/test/core/services/geo_lookup_service_test.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:lantern/core/services/geo_lookup_service.dart'; + +void main() { + setUp(GeoLookupService.resetCacheForTest); + + test('concurrent peer lookups for one IP share a request', () async { + var requests = 0; + final results = await http.runWithClient( + () { + return Future.wait([ + GeoLookupService.peerLookup('192.0.2.1'), + GeoLookupService.peerLookup('192.0.2.1'), + ]); + }, + () => MockClient((request) async { + requests++; + await Future.delayed(const Duration(milliseconds: 20)); + return http.Response( + jsonEncode({ + 'Country': { + 'IsoCode': 'US', + 'Names': {'en': 'United States'}, + }, + }), + 200, + ); + }), + ); + expect(requests, 1); + expect(results.map((g) => g.countryCode), ['US', 'US']); + // Later calls hit the cache. + await GeoLookupService.peerLookup('192.0.2.1'); + expect(requests, 1); + }); +} diff --git a/test/features/action_mode/action_mode_widgets_test.dart b/test/features/action_mode/action_mode_widgets_test.dart index 61e12fa688..e367cc6da7 100644 --- a/test/features/action_mode/action_mode_widgets_test.dart +++ b/test/features/action_mode/action_mode_widgets_test.dart @@ -12,12 +12,14 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_setting.dart'; +import 'package:lantern/core/models/relay_stats.dart'; import 'package:lantern/core/models/share_state.dart'; import 'package:lantern/core/widgets/info_row.dart'; import 'package:lantern/features/action_mode/action_mode_globe.dart'; import 'package:lantern/features/action_mode/peer_status_pill.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/action_mode/action_mode_widgets.dart'; +import 'package:lantern/features/action_mode/advanced_stats_sheet.dart'; import 'package:lantern/features/action_mode/auto_enable_mode.dart'; import 'package:lantern/features/action_mode/provider/share_notifier.dart'; import 'package:lantern/features/action_mode/action_mode.dart'; @@ -488,4 +490,137 @@ void main() { }, ); } + + const relay = RelayStats( + available: true, + speedDown: 1260000, + speedUp: 48000, + sessionDown: 340000000, + sessionUp: 12000000, + lifetimeDown: 1200000000, + lifetimeUp: 30000000, + countries: { + 'IR': RelayCountryStats( + code: 'IR', + name: 'Iran', + down: 900000000, + up: 20000000, + people: 4, + ), + 'RU': RelayCountryStats( + code: 'RU', + name: 'Russia', + down: 200000000, + up: 5000000, + people: 1, + ), + '': RelayCountryStats(code: '', down: 1000), + }, + ); + + for (final brightness in Brightness.values) { + testWidgets('advanced stats link opens the sheet ($brightness)', ( + tester, + ) async { + await mount( + tester, + const ActionModeTab(), + brightness: brightness, + shareState: const ShareState( + active: true, + mode: ShareMode.unbounded, + unboundedRunning: true, + relay: relay, + ), + animated: true, + ); + final link = find.byKey(const Key('action-mode.advanced-stats')); + await tester.ensureVisible(link); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(link); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(AdvancedStatsView), findsOneWidget); + expect(find.text('↓ 1.3 MB/s ↑ 48 kB/s'), findsOneWidget); + expect(find.text('352 MB'), findsOneWidget); + expect(find.text('1.2 GB'), findsOneWidget); + expect(find.text('920 MB · 4 people'), findsOneWidget); + expect(find.text('205 MB · 1 person'), findsOneWidget); + expect(find.text('Unknown'), findsOneWidget); + expect(find.text('1.0 kB'), findsOneWidget); + expect(find.textContaining('0 people'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('advanced stats orders countries by data', (tester) async { + await mount( + tester, + const AdvancedStatsView(), + shareState: const ShareState( + active: true, + mode: ShareMode.unbounded, + relay: relay, + ), + ); + final iran = tester.getTopLeft(find.text('Iran')).dy; + final russia = tester.getTopLeft(find.text('Russia')).dy; + final unknown = tester.getTopLeft(find.text('Unknown')).dy; + expect(iran, lessThan(russia)); + expect(russia, lessThan(unknown)); + expect(find.byKey(const Key('advanced-stats.smc-note')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('advanced stats shows an empty state at large text', ( + tester, + ) async { + await mount( + tester, + const AdvancedStatsView(), + size: const Size(320, 568), + scale: 2, + shareState: const ShareState( + relay: RelayStats(available: true, speedDown: 1260000, speedUp: 48000), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('0 B'), findsWidgets); + final empty = find.text( + 'No one helped yet. Countries will appear here once people connect ' + 'through you.', + ); + await tester.dragUntilVisible( + empty, + find.byKey(const Key('advanced-stats.list')), + const Offset(0, -200), + ); + expect(empty, findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('advanced stats in SmC shows a note instead of zeros', ( + tester, + ) async { + await mount( + tester, + const AdvancedStatsView(), + shareState: const ShareState( + active: true, + mode: ShareMode.smc, + phase: SharePhase.serving, + relay: relay, + ), + ); + expect(find.byKey(const Key('advanced-stats.smc-note')), findsOneWidget); + expect( + find.text('Data stats are available only when sharing via Unbounded.'), + findsOneWidget, + ); + expect(find.text('Current speed'), findsNothing); + expect(find.text('Data relayed this session'), findsNothing); + expect(find.text('Data relayed all time'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } diff --git a/test/features/action_mode/snapshot_test.dart b/test/features/action_mode/snapshot_test.dart index 4912076705..f4b83f4f32 100644 --- a/test/features/action_mode/snapshot_test.dart +++ b/test/features/action_mode/snapshot_test.dart @@ -16,6 +16,7 @@ import 'package:lantern/features/home/provider/radiance_settings_providers.dart' import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/models/app_event.dart'; import 'package:lantern/core/models/app_setting.dart'; +import 'package:lantern/core/models/relay_stats.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/core/models/share_state.dart'; import 'package:lantern/features/action_mode/provider/share_notifier.dart'; @@ -49,12 +50,54 @@ class FakeService implements LanternService { } class FakeSettings extends AppSettingNotifier { + int relayWrites = 0; @override - AppSetting build() => const AppSetting(unboundedTotalHelped: 10); + AppSetting build() => seed; + + static const seed = AppSetting( + unboundedTotalHelped: 10, + unboundedBytesDown: 1000000, + unboundedBytesUp: 2000, + unboundedCountryStats: { + 'US': RelayCountryStats( + code: 'US', + name: 'United States', + down: 900, + people: 2, + ), + }, + ); @override void setUnboundedTotalHelped(int value) { state = state.copyWith(unboundedTotalHelped: value); } + + @override + void setUnboundedRelayStats({ + required int bytesDown, + required int bytesUp, + required Map countries, + ({String epoch, int down, int up})? mark, + }) { + relayWrites++; + state = state.copyWith( + unboundedBytesDown: bytesDown, + unboundedBytesUp: bytesUp, + unboundedCountryStats: countries, + unboundedRelayEpoch: mark?.epoch, + unboundedRelayMarkDown: mark?.down, + unboundedRelayMarkUp: mark?.up, + ); + } +} + +class ResumingSettings extends FakeSettings { + @override + AppSetting build() => FakeSettings.seed.copyWith( + unboundedRelayEpoch: 'run-1', + unboundedRelayMarkDown: 4000, + unboundedRelayMarkUp: 400, + ); } class FakeRadianceSettings extends RadianceSettings { @@ -67,6 +110,8 @@ class FakeRadianceSettings extends RadianceSettings { onSetPeerProxy?.call(value) ?? startResult.future; } +const unknownIp = '198.51.100.9'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late FakeService service; @@ -80,13 +125,14 @@ void main() { '192.0.2.1', '192.0.2.2', '192.0.2.251', + unknownIp, ].map(GeoLookupService.peerLookup), ), () => MockClient( - (_) async => http.Response( + (request) async => http.Response( jsonEncode({ 'Country': { - 'IsoCode': 'US', + 'IsoCode': request.url.path.endsWith(unknownIp) ? '' : 'US', 'Names': {'en': 'United States'}, }, }), @@ -116,6 +162,7 @@ void main() { List peers, { int arrivals = 2, String epoch = "run-1", + Map bytes = const {}, }) async { service.events.add( AppEvent( @@ -126,6 +173,7 @@ void main() { 'enabled': enabled, 'running': running, 'peers': peers, + ...bytes, }), ), ); @@ -215,6 +263,192 @@ void main() { expect(container.read(shareProvider).totalCount, 11); }); + group('relay stats', () { + late DateTime now; + late FakeSettings settings; + setUp(() { + now = DateTime(2026, 10, 17); + container.read(shareProvider.notifier).clock = () => now; + settings = container.read(appSettingProvider.notifier) as FakeSettings; + }); + Map counters( + int down, + int up, + Map peers, + ) => { + 'bytesDown': down, + 'bytesUp': up, + 'peerBytes': [ + for (final e in peers.entries) + {'ip': e.key, 'down': e.value.$1, 'up': e.value.$2}, + ], + }; + Future tick( + int down, + int up, + Map peers, { + List present = const ['192.0.2.1'], + String epoch = 'run-1', + }) async { + now = now.add(const Duration(seconds: 1)); + await snapshot( + true, + true, + present, + epoch: epoch, + bytes: counters(down, up, peers), + ); + } + + RelayStats relay() => container.read(shareProvider).relay; + + test('seeds lifetime totals from settings', () { + expect(relay().lifetimeDown, 1000000); + expect(relay().lifetimeUp, 2000); + expect(relay().countries['US']!.people, 2); + expect(relay().available, isFalse); + }); + + test('baseline, deltas, speed, and per-country attribution', () async { + // Counters already accumulated before this app run are not recounted. + await tick(5000, 500, {'192.0.2.1': (5000, 500)}); + expect(relay().available, isTrue); + expect(relay().sessionBytes, 0); + expect(relay().lifetimeDown, 1000000); + expect(relay().speedDown, 0); + + // 192.0.2.1 keeps going; the unknown peer came and went between + // snapshots and only shows up in peerBytes. + await tick(7000, 700, {'192.0.2.1': (6500, 650), unknownIp: (500, 50)}); + expect(relay().sessionDown, 2000); + expect(relay().sessionUp, 200); + expect(relay().lifetimeDown, 1002000); + expect(relay().lifetimeUp, 2200); + expect(relay().speedDown, 1000); + expect(relay().speedUp, 100); + final us = relay().countries['US']!; + expect((us.down, us.up, us.people), (2400, 150, 2)); + final unknown = relay().countries[RelayCountryStats.unknownCode]!; + expect((unknown.down, unknown.up, unknown.people), (500, 50, 1)); + expect(unknown.isUnknown, isTrue); + + // A new US arrival counts a person even before it moves bytes. + await tick( + 7000, + 700, + {'192.0.2.1': (6500, 650), unknownIp: (500, 50)}, + present: ['192.0.2.1', '192.0.2.2'], + ); + expect(relay().countries['US']!.people, 3); + expect(relay().speedDown, 500); + }); + + test('counter resets and new epochs count from zero', () async { + await tick(5000, 500, {'192.0.2.1': (5000, 500)}); + await tick(100, 600, {'192.0.2.1': (100, 600)}); + expect(relay().sessionDown, 100); + expect(relay().sessionUp, 600); + // A reset is not a rate over one interval, so speed does not spike. + expect(relay().speedDown, 0); + await tick(300, 30, {'192.0.2.2': (300, 30)}, epoch: 'run-2'); + expect(relay().sessionDown, 400); + expect(relay().sessionUp, 630); + expect(relay().countries['US']!.down, 900 + 100 + 300); + }); + + test('persists on a throttle and when sharing stops', () async { + // The baseline moves no bytes but saves its watermark. + await tick(0, 0, {}); + expect(settings.relayWrites, 1); + expect(container.read(appSettingProvider).unboundedRelayEpoch, 'run-1'); + await tick(0, 0, {}); + expect(settings.relayWrites, 1); + now = now.add(const Duration(seconds: 30)); + await tick(1000, 0, {'192.0.2.1': (1000, 0)}); + expect(settings.relayWrites, greaterThanOrEqualTo(1)); + final writes = settings.relayWrites; + await tick(2000, 0, {'192.0.2.1': (2000, 0)}); + await tick(3000, 0, {'192.0.2.1': (3000, 0)}); + expect(settings.relayWrites, writes); + now = now.add(const Duration(seconds: 30)); + await tick(4000, 0, {'192.0.2.1': (4000, 0)}); + expect(settings.relayWrites, writes + 1); + expect(container.read(appSettingProvider).unboundedBytesDown, 1004000); + expect(container.read(appSettingProvider).unboundedRelayMarkDown, 4000); + expect(container.read(appSettingProvider).unboundedRelayEpoch, 'run-1'); + await tick(5000, 0, {'192.0.2.1': (5000, 0)}); + service.events.add( + AppEvent(eventType: 'unbounded-unavailable', message: '{}'), + ); + await Future.delayed(Duration.zero); + expect(settings.relayWrites, writes + 2); + expect(container.read(appSettingProvider).unboundedBytesDown, 1005000); + expect(relay().speedDown, 0); + }); + + test('a stopped snapshot saves its final counters at once', () async { + await tick(0, 0, {}); + await tick(1000, 0, {'192.0.2.1': (1000, 0)}); + final writes = settings.relayWrites; + now = now.add(const Duration(seconds: 1)); + await snapshot( + false, + false, + [], + bytes: counters(1500, 0, {'192.0.2.1': (1500, 0)}), + ); + expect(settings.relayWrites, writes + 1); + expect(container.read(appSettingProvider).unboundedBytesDown, 1001500); + expect(container.read(appSettingProvider).unboundedRelayMarkDown, 1500); + }); + + test('a throttled save is deferred, not dropped', () async { + container.read(shareProvider.notifier) + ..relayPersistInterval = const Duration(milliseconds: 50) + ..clock = DateTime.now; + await tick(0, 0, {}); + await tick(1000, 0, {'192.0.2.1': (1000, 0)}); + final writes = settings.relayWrites; + await tick(2000, 0, {'192.0.2.1': (2000, 0)}); + expect(settings.relayWrites, writes); + await Future.delayed(const Duration(milliseconds: 120)); + expect(settings.relayWrites, writes + 1); + expect(container.read(appSettingProvider).unboundedBytesDown, 1002000); + }); + + test('resumes from the persisted watermark after an app restart', () async { + final resumed = ProviderContainer( + overrides: [ + lanternServiceProvider.overrideWithValue(service), + appSettingProvider.overrideWith(ResumingSettings.new), + radianceSettingsProvider.overrideWith(FakeRadianceSettings.new), + ], + ); + addTearDown(resumed.dispose); + resumed.read(shareProvider); + await snapshot(true, true, [ + '192.0.2.1', + ], bytes: counters(6000, 500, {'192.0.2.1': (6000, 500)})); + final relay = resumed.read(shareProvider).relay; + expect(relay.lifetimeDown, 1000000 + 2000); + expect(relay.lifetimeUp, 2000 + 100); + // Peer counters only set a baseline, so per-country totals hold. + expect(relay.countries['US']!.down, 900); + }); + + test( + 'snapshots without byte counters (older radiance) are tolerated', + () async { + await snapshot(true, true, ['192.0.2.1']); + await snapshot(true, true, ['192.0.2.1', '192.0.2.2']); + expect(relay().available, isFalse); + expect(relay().sessionBytes, 0); + expect(relay().lifetimeDown, 1000000); + expect(relay().countries['US']!.people, 3); + }, + ); + }); + testWidgets('pending auto-start ignores failure after disposal', ( tester, ) async { @@ -432,4 +666,64 @@ void main() { }, ); } + + testWidgets('counts final Unbounded bytes that arrive during SmC', ( + tester, + ) async { + late WidgetRef widgetRef; + late BuildContext context; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: Consumer( + builder: (ctx, ref, child) { + widgetRef = ref; + context = ctx; + return const SizedBox(); + }, + ), + ), + ); + final radiance = + container.read(radianceSettingsProvider.notifier) + as FakeRadianceSettings; + service.onSetUnbounded = (_) async => right(unit); + radiance.onSetPeerProxy = (_) async => right(unit); + Future emit(bool on, int down) async { + service.events.add( + AppEvent( + eventType: 'unbounded-snapshot', + message: jsonEncode({ + 'epoch': 'run-1', + 'arrivals': 1, + 'enabled': on, + 'running': on, + 'peers': on ? ['192.0.2.1'] : [], + 'bytesDown': down, + 'bytesUp': 0, + 'peerBytes': [ + {'ip': '192.0.2.1', 'down': down, 'up': 0}, + ], + }), + ), + ); + await tester.pump(); + } + + await emit(true, 0); + await emit(true, 1000); + final notifier = container.read(shareProvider.notifier); + // Stop Unbounded, then start SmC before the backend's final snapshot. + await notifier.toggle(context, widgetRef); + await notifier.toggle(context, widgetRef); + expect(container.read(shareProvider).mode, ShareMode.smc); + await emit(false, 1500); + final state = container.read(shareProvider); + expect(state.mode, ShareMode.smc); + expect(state.active, isTrue); + expect(state.relay.lifetimeDown, 1001500); + final saved = container.read(appSettingProvider); + expect(saved.unboundedBytesDown, 1001500); + expect(saved.unboundedRelayMarkDown, 1500); + }); }