Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,70 @@ back to `hop_bytes`, independently of regional TX path-width enforcement.
Coverage requests use up to 40 characters of the full key to meet the API's
prefix limit. Legacy narrow coverage tokens can remain inherently ambiguous.

### Repeater Nicknames

A user can give a repeater a short personal nickname ("Mell" for the repeater
whose key starts 5007) that is shown where a heard repeater is named. It stays
on the phone: no account sync and no server involvement. Nicknames are kept in
the Hive `user_preferences` box under `repeater_nicknames`, a map of full
64-hex public key to `{nickname, name, hex}`, and read back through
`RepeaterNicknames.sanitize` (`lib/utils/repeater_nicknames.dart`), which drops
a bad entry instead of throwing. `name` and `hex` are snapshots of the server
name and display hex taken at save time, so the Settings list can describe a
repeater outside the current zone's catalogue; they are display-only and never
used to match. A nickname is capped at 8 characters
(`RepeaterNicknames.maxLength`), and only a repeater with a full public key can
take one.

Nicknames are global to the phone and never scoped to a zone or IATA, so they
survive leaving a region and coming back. Nothing clears them on a zone change,
catalogue reload, disconnect, log clear or session end, and resolving against
whatever catalogue is loaded brings one back when its repeater is in view
again. The only thing that drops them is the existing corrupt-box recovery
(`_openBoxSafely`) that deletes the whole `user_preferences` box, like every
other preference.

Matching is by full key. A heard ID shows a nickname only when it resolves to
exactly one catalogue repeater, trying the fullest identity the row arrived
with first and the display hash second; an ambiguous ID keeps its hex, because
a confidently wrong name is worse than none. Top Heard
(`AppStateProvider.topHeardLabel`, via `RepeaterNicknames.labelFor`), the Live
Activity and Siri all resolve through `ExternalSurfaceGeoBuilder.resolveOverlayRow`;
the watch's heard rows use `resolveOverlayRepeaters`, which applies the same
identity-first, unique-only rule to the whole set of rows at once.

The nickname replaces the hex on the Top Heard overlay. On the Live Activity and
CarPlay heard rows (`_resolveRepeaterDisplayName`) it wins over the server name
in the existing name slot beside the hex, still behind "Repeater Names on Live
Activity". It also appears on the Apple Watch heard rows
(`buildHeard(nicknames:)`) and in Siri's heard observations
(`buildRecentHeard(nicknames:)`). `_resolveRepeaterDisplayName` also feeds
`_targetRepeaterDisplayName`, so in Trace mode the nickname names the target in
the session phase text on the Live Activity and watch ("Tracing <name>",
"Waiting for <name> to reply", "To <name>"). That text was never behind the
"Repeater Names on Live Activity" toggle; only the heard rows are. It is not
used in log chips, ping popups, map markers or the Siri/Shortcuts repeater
catalogue, which keep server names. A change goes through
`setRepeaterNickname` or `setRepeaterNicknameByKey`, which bump
`_siriRepeaterCatalogRevision`, call `_notifyMapNow()` (the overlay rebuilds on
`mapRevision`) and schedule the Live Activity, watch and Siri sync.

On the map, the minimized repeater pill shows the nickname as a small tag after
the name. The expanded repeater sheet has a Nickname button beside Manage, the
same height and shape, reading "Nickname" until one is set and then the
nickname itself, for repeaters with a full public key only (without one, Manage
keeps the full width). The tag, the button and the hex badge are the shared
`RepeaterNicknameTag`, `RepeaterNicknameButton` and `RepeaterHexBadge` in
`lib/widgets/repeater_nickname_widgets.dart`. The
other entry point is Settings > Map > Repeater Nicknames
(`RepeaterNicknameList` in `lib/screens/settings/repeater_nicknames_page.dart`):
list, add through the repeater picker (which offers only repeaters with a full
key), edit and remove. A removal offers Undo, and removals made while that bar
is up share it (`NicknameRemovalBatch`), so one Undo brings them all back. The
sheet and the list's add share one ask-then-save flow, `editRepeaterNickname`
in `lib/widgets/repeater_nickname_dialog.dart`, and nicknames made only of
invisible characters are treated as blank.

### Service-Oriented Architecture

The app uses a layered service architecture with clear separation of concerns:
Expand Down Expand Up @@ -2294,6 +2358,8 @@ carries the responder's full 64-character public key, and a trace carries its
`_overlayIdentityById`, **replaced wholesale per ping and never merged**, because
a hash that meant one repeater in a discovery response says nothing about who a
later TX echo under the same hash was.
A user's own nickname for the resolved repeater wins over the server name; see
"Repeater Nicknames".

TX echoes and passive RX carry only the path byte, so they fall back to prefix
matching and keep refusing to guess. That fallback indexes per *distinct*
Expand Down Expand Up @@ -2977,5 +3043,9 @@ All API endpoints may return maintenance mode:
- `lib/utils/repeater_marker_style.dart` - Repeater marker geometry, the five-state colour registry, and the cluster dominant/presence expressions
- `lib/utils/cluster_spread.dart` - The zoom that pulls a repeater cluster apart, deciding zoom-vs-spread on tap
- `lib/utils/repeater_marker_painter.dart` - Draws the repeater chip, the cluster badge disc and its presence dots
- `lib/utils/repeater_nicknames.dart` - Personal repeater nicknames: `RepeaterNicknames.sanitize`, the 8-character cap, and resolution of a heard row to its nickname
- `lib/widgets/repeater_nickname_dialog.dart` - The dialog that sets or removes a nickname, shared by the map sheet and Settings
- `lib/widgets/repeater_nickname_widgets.dart` - `RepeaterHexBadge`, `RepeaterNicknameTag` and `RepeaterNicknameButton`, the pieces the nickname UI is drawn from
- `lib/screens/settings/repeater_nicknames_page.dart` - Settings > Map > Repeater Nicknames: the list with add, edit, remove and Undo
- `lib/models/noise_floor_session.dart` - Noise floor session data models
- `lib/widgets/noise_floor_chart.dart` - Noise floor graph visualization
2 changes: 1 addition & 1 deletion docs/LIVE_ACTIVITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ MeshMapper starts one read-only Live Activity while an automatic wardriving sess

- Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states)
- System-rendered countdowns based on absolute timer deadlines
- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them
- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them. Repeater names (when "Repeater Names on Live Activity" is on) show the user's own nickname when set in Settings > Map > Repeater Nicknames, otherwise the server name
- TX/RX counters, upload queue, zone, connection state, and stale-update warnings
- Lock Screen, Dynamic Island, and one iOS 18 small-family layout shared by the paired watch's Smart Stack card and the CarPlay dashboard. CarPlay's canvas overlaps the watch card sizes and nothing names the surface, so the card solves its row count and font from the measured height instead of guessing

Expand Down
200 changes: 175 additions & 25 deletions lib/providers/app_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ import '../services/repeater_admin/repeater_admin_module.dart';
import '../services/repeater_admin/repeater_admin_session.dart';
import '../utils/constants.dart';
import '../utils/geo_validation.dart';
import '../utils/public_key.dart';
import '../utils/repeater_collision.dart';
import '../utils/repeater_nicknames.dart';
import '../utils/ping_colors.dart';
import '../utils/radio_filter.dart' as radio_filter;
import '../utils/zone_group.dart';
Expand Down Expand Up @@ -844,6 +846,132 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
return null;
}

// ============================================
// Repeater nicknames (personal labels, kept on the phone)
// ============================================

/// Box key for the saved nicknames, an untyped map in the preferences box.
static const String _repeaterNicknamesKey = 'repeater_nicknames';

/// The user's nicknames, keyed by the repeater's full public key.
///
/// Global to the phone and never scoped to a zone or IATA: a nickname must
/// survive leaving a region and coming back. Nothing clears or filters this
/// on a zone change, a catalogue reload, a disconnect, a log clear or a
/// session end. Only [setRepeaterNickname] and [setRepeaterNicknameByKey]
/// change it, and resolution against whatever catalogue is loaded brings a
/// nickname back when its repeater is in view again.
Map<String, RepeaterNickname> _repeaterNicknames = const {};

/// Every saved nickname as (full public key, entry), sorted by nickname
/// without regard to case, then by key so two repeaters given the same
/// nickname keep their order. Unmodifiable.
List<MapEntry<String, RepeaterNickname>> get repeaterNicknames =>
List.unmodifiable(_repeaterNicknames.entries.toList()
..sort((a, b) {
final byName = a.value.nickname
.toLowerCase()
.compareTo(b.value.nickname.toLowerCase());
return byName != 0 ? byName : a.key.compareTo(b.key);
}));

/// How many nicknames are saved.
int get repeaterNicknameCount => _repeaterNicknames.length;

/// The nickname saved for [repeater], or null when it has none.
String? nicknameForRepeater(Repeater repeater) =>
RepeaterNicknames.nicknameFor(repeater, _repeaterNicknames);

/// The text for a Top Heard row: its nickname when the row resolves to one
/// repeater that has one, else [repeaterId] unchanged.
String topHeardLabel(String repeaterId) {
final id = repeaterId.toUpperCase();
return RepeaterNicknames.labelFor(
displayId: id,
identity: _overlayIdentityById[id],
repeaters: _repeaters,
hopBytes: _hopBytes,
nicknames: _repeaterNicknames,
) ??
repeaterId;
}

/// Give [repeater] the nickname [nickname], or remove its nickname when
/// [nickname] is null or blank. Does nothing for a repeater without a full
/// public key, which the UI never offers.
Future<void> setRepeaterNickname(Repeater repeater, String? nickname) async {
final key = normalizePublicKey(repeater.hexId);
if (key == null) {
debugWarn('[SETTINGS] Repeater nickname ignored: no full public key');
return;
}
final n = RepeaterNicknames.normalizeNickname(nickname);
await _onRepeaterNicknamesChanged(
key,
set: n == null
? null
: RepeaterNickname(
nickname: n,
name: repeater.name,
hex: repeater.displayHexId(),
),
);
}

/// Like [setRepeaterNickname] for a repeater known only by [publicKey], as
/// in the Settings list, whose entries may not be in the current catalogue.
///
/// [name] and [hex] describe the repeater in the list: the arguments when
/// given (Undo re-adds a removed entry this way), else the existing entry's.
Future<void> setRepeaterNicknameByKey(
String publicKey,
String? nickname, {
String? name,
String? hex,
}) async {
final key = normalizePublicKey(publicKey);
if (key == null) {
debugWarn('[SETTINGS] Repeater nickname ignored: not a full public key');
return;
}
final n = RepeaterNicknames.normalizeNickname(nickname);
final existing = _repeaterNicknames[key];
await _onRepeaterNicknamesChanged(
key,
set: n == null
? null
: RepeaterNickname(
nickname: n,
name: name ?? existing?.name ?? '',
hex: hex ?? existing?.hex ?? key.substring(0, 4),
),
);
}

/// Apply one change to the nicknames: [set] is the new entry for [key], or
/// null to remove it. Replaces the map rather than editing it, since the
/// getters hand out views of it.
Future<void> _onRepeaterNicknamesChanged(
String key, {
required RepeaterNickname? set,
}) async {
final next = Map<String, RepeaterNickname>.of(_repeaterNicknames);
if (set == null) {
next.remove(key);
} else {
next[key] = set;
}
_repeaterNicknames = Map.unmodifiable(next);
// Part of the Siri preflight key, so the snapshot rebuilds.
_siriRepeaterCatalogRevision++;
// The Top Heard overlay only rebuilds on the map revision.
_notifyMapNow();
_scheduleLiveActivitySync();
await _saveRepeaterNicknames();
debugLog('[SETTINGS] Repeater nickname '
'${set != null ? 'set' : 'cleared'} for ${_pkPrefix(key)}');
}

// ============================================
// Radio preset (multiple radio configurations per region)
// ============================================
Expand Down Expand Up @@ -2278,6 +2406,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
rxAt: _liveActivityRxUpdatedAt,
lat: ranking?.lat,
lon: ranking?.lon,
nicknames: _repeaterNicknames,
);

// The readout still needs the fix and Top Heard, but none of the arrays
Expand Down Expand Up @@ -2567,6 +2696,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
traceEntries: _traceLogEntries,
repeaters: _repeaters,
hopBytes: _hopBytes,
nicknames: _repeaterNicknames,
);
final phase = _resolveWatchPhase();
final passive = sessionStartAvailability(AutoMode.passive);
Expand Down Expand Up @@ -3432,31 +3562,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {

String? _resolveRepeaterDisplayName(String rawId) {
final displayId = rawId.toUpperCase();

// Try the fullest identity the row arrived with, then the display hash.
//
// **The fallback is the point.** A discovery response's public key is a
// better identity, but the catalogue is matched by an 8-character `hexId`
// that the API frequently omits, and the display hash additionally resolves
// through `displayHexId` — which falls back to the short numeric ID when
// there is no hex at all. Trying only the longer form threw away the lookup
// that was doing the work, and left named repeaters unnamed.
final identity = _overlayIdentityById[displayId];
final match = (identity == null
? null
: ExternalSurfaceGeoBuilder.resolveRepeater(
repeaters: _repeaters,
id: identity,
hopBytes: _hopBytes,
)) ??
ExternalSurfaceGeoBuilder.resolveRepeater(
repeaters: _repeaters,
id: displayId,
hopBytes: _hopBytes,
);

final name = match?.name;
return name == null || name == 'Unknown' ? null : name;
// Why the display hash is tried as well as the identity: see
// resolveOverlayRow.
final match = ExternalSurfaceGeoBuilder.resolveOverlayRow(
displayId: displayId,
identity: _overlayIdentityById[displayId],
repeaters: _repeaters,
hopBytes: _hopBytes,
);
if (match == null) return null;
// The user's nickname wins over the server's name.
final nickname = RepeaterNicknames.nicknameFor(match, _repeaterNicknames);
if (nickname != null) return nickname;
final name = match.name;
return name == 'Unknown' ? null : name;
}

// ============================================
Expand Down Expand Up @@ -3726,6 +3845,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
debugLog('[INIT] Loading preferences...');
await _loadPreferences();
await _loadRegionalCarpeaters();
await _loadRepeaterNicknames();
await _loadRepeaterClaims();
await _loadScopeDiscoveryState();
await _loadLastRadioConfig();
Expand Down Expand Up @@ -12507,6 +12627,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_syncCarpeaterFilter();
}

Future<void> _loadRepeaterNicknames() async {
final box = await _openBoxSafely(_preferencesBoxName);
if (box == null) return;
try {
_repeaterNicknames =
RepeaterNicknames.sanitize(box.get(_repeaterNicknamesKey));
debugLog('[APP] Loaded ${_repeaterNicknames.length} repeater nicknames');
} catch (e) {
debugError('[APP] Failed to load repeater nicknames: $e');
_repeaterNicknames = const {};
}
}

Future<void> _saveRepeaterNicknames() async {
final box = await _openBoxSafely(_preferencesBoxName);
if (box == null) {
debugError('[APP] Repeater nicknames not saved: preferences unavailable');
return;
}
try {
await box.put(
_repeaterNicknamesKey,
RepeaterNicknames.toStorage(_repeaterNicknames),
);
await box.flush();
} catch (e) {
debugError('[APP] Failed to save repeater nicknames: $e');
}
}

Future<void> _loadLastRadioConfig() async {
final box = await _openBoxSafely(_preferencesBoxName);
if (box == null) return;
Expand Down
20 changes: 20 additions & 0 deletions lib/screens/settings/map_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';

import '../../providers/app_state_provider.dart';
import '../offline_maps_screen.dart';
import 'repeater_nicknames_page.dart';
import 'settings_section_card.dart';

/// Settings folder: Map.
Expand All @@ -17,6 +18,7 @@ class MapSettingsPage extends StatelessWidget {
Widget build(BuildContext context) {
final appState = context.watch<AppStateProvider>();
final prefs = appState.preferences;
final nicknameCount = appState.repeaterNicknameCount;

return Scaffold(
appBar: AppBar(
Expand Down Expand Up @@ -134,6 +136,24 @@ class MapSettingsPage extends StatelessWidget {
prefs.copyWith(largerTopRepeaters: value));
},
),
ListTile(
leading: const Icon(Icons.label_outline),
title: const Text('Repeater Nicknames'),
subtitle: Text(nicknameCount == 0
? 'Your own short names for Top Heard'
: nicknameCount == 1
? '1 nickname'
: '$nicknameCount nicknames'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const RepeaterNicknamesPage(),
),
);
},
),
]),
],
),
Expand Down
Loading
Loading