feat: render discovered interfaces as map pins with type filtering#729
feat: render discovered interfaces as map pins with type filtering#729torlando-tech merged 14 commits intomainfrom
Conversation
Foundation for rendering discovered interfaces as map pins: - Make InterfaceCategory public with mdiIconName and markerColor properties for map marker rendering - Add InterfaceMarker data class with location, radio, and TCP fields - Extend MapState with interfaceMarkers and interfaceFilterEnabled - Inject ReticulumProtocol into MapViewModel for interface discovery - Add loadInterfaceMarkers() with 30s periodic refresh - Add toggleInterfaceFilter() for per-category filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Discovered network interfaces with GPS coordinates are now shown on the map as rounded-rectangle pins with type-specific icons and colors: - LoRa/RNode: orange antenna icon - TCP: blue cloud icon - BLE: indigo bluetooth icon - Auto/Local: green wifi icon Features: - Filter chips below the TopAppBar to toggle interface types on/off - Tap any interface pin to see details in a bottom sheet (reuses existing FocusInterfaceBottomSheet with RNode params, TCP info, etc.) - Interface pins render below contact markers to avoid overlap - Refreshes every 30s alongside contact marker refresh - Category bitmaps are cached (only 4-6 images, not per-marker) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR renders discovered Reticulum network interfaces that have GPS coordinates as map pins with type-specific icons, filter chips to toggle categories, and a tap-to-inspect bottom sheet. It fits naturally into the existing map architecture: a new Key findings:
Confidence Score: 5/5Safe to merge; all findings are P2 style/quality suggestions with no confirmed runtime defects All four findings are P2: the null-byte separator is unconventional but works correctly with Room/SQLite; the Yggdrasil address range is intentional for the targeted version; the empty UNKNOWN label is a cosmetic gap; and missing tests are additive coverage gaps. No P0/P1 crash, data-loss, or security issues were found. The architecture is clean, existing tests pass, and the DB migration is correct. app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt (Yggdrasil address range + UNKNOWN label) and app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt (composite ID separator) Important Files Changed
Sequence DiagramsequenceDiagram
participant VM as MapViewModel
participant RP as ReticulumProtocol
participant DB as InterfaceFirstSeenDao
participant S as MapState
participant UI as MapScreen
VM->>RP: getDiscoveredInterfaces()
RP-->>VM: List<DiscoveredInterface> (with hasLocation)
VM->>DB: insertIfNotExists(id, now) per interface
VM->>DB: getFirstSeenBatch(ids)
DB-->>VM: List<InterfaceFirstSeenEntity>
VM->>S: update(interfaceMarkers = markers)
S-->>UI: filteredInterfaceMarkers (StateFlow)
UI->>UI: LaunchedEffect → update GeoJSON source
UI->>UI: Render interface-markers-layer below contact-markers-layer
UI->>UI: Render FilterChips (by category)
Note over UI: User taps pin
UI->>UI: queryRenderedFeatures(interface-markers-layer)
UI->>S: find InterfaceMarker by id
UI->>UI: selectedInterface = marker → show FocusInterfaceBottomSheet
Prompt To Fix All With AIThis is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt
Line: 668
Comment:
**Null-byte delimiters create fragile composite keys**
The interface ID uses `\u0000` (null byte) as a field separator:
```kotlin
val id = "${iface.name}\u0000${iface.type}\u0000${iface.reachableOn ?: ""}"
```
Two concerns:
1. **Theoretical collision**: if any field itself contained a null byte, segments would bleed across boundaries (e.g. `name="foo\u0000bar"`, `type="baz"` → same key as `name="foo"`, `type="bar\u0000baz"`). Unlikely in practice for network interface strings, but the invariant is invisible at the call site.
2. **Debuggability**: IDs stored/logged as SQLite `TEXT` will appear truncated in `adb shell sqlite3` and most log viewers, making it hard to spot duplicate-key issues.
A plain printable separator (e.g. `"|"` or `"::"`) or a hash of the triple would be safer:
```suggestion
val id = "${iface.name}|${iface.type}|${iface.reachableOn ?: ""}"
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt
Line: 108-113
Comment:
**`isYggdrasilHost` only covers the old Yggdrasil address space (0200::/7)**
Yggdrasil v0.5 (released 2023) changed the self-assigned address prefix from `200::/7` to `fc00::/8` (i.e. first 16-bit segment `0xfc00`–`0xfdff`). Any user running a v0.5+ Yggdrasil node would have their TCP interface over a Yggdrasil address categorised as plain `TCP` instead of `YGGDRASIL`.
If v0.5+ support is in scope, extend the range check:
```kotlin
private fun isYggdrasilHost(host: String?): Boolean {
if (host == null) return false
val clean = host.trim().removePrefix("[").removeSuffix("]")
val firstSegment = clean.takeIf { it.contains(":") }?.split(":")?.firstOrNull()
val value = firstSegment?.toIntOrNull(16) ?: return false
// 0200::/7 (old Yggdrasil) OR fc00::/8 (Yggdrasil v0.5+)
return value in 0x0200..0x03FF || value in 0xFC00..0xFDFF
}
```
If only the old address range is intentional, a short comment explaining this would avoid confusion for future contributors.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt
Line: 35
Comment:
**`UNKNOWN` category filter chip renders with an empty label**
`UNKNOWN.defaultText` is `""`. If any discovered interface can't be categorised and has GPS coordinates, the filter chip rendered in `MapScreen` will show just the antenna icon with no label text — visually broken:
```kotlin
label = {
Text(
category.defaultText, // "" for UNKNOWN
style = MaterialTheme.typography.labelSmall,
)
},
```
Consider either a non-empty fallback like `"Other"`, or exclude `UNKNOWN` from the chip row entirely.
```suggestion
UNKNOWN(Icons.Default.SettingsInputAntenna, com.composables.icons.lucide.R.drawable.lucide_ic_antenna, "Other", 0xFF9E9E9E.toInt()),
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt
Line: 305-315
Comment:
**No test coverage for newly added interface categories**
`InterfaceInfoTest` was updated only to reflect the `Cloud → Public` icon change for TCP, but there are no tests for the four newly introduced categorisation branches:
- `I2P` (name contains `"i2p"`)
- `YGGDRASIL` (TCP name + Yggdrasil host via `isYggdrasilHost`)
- `LORA` for `"weave"` / `"kiss"` name patterns
- `categorizeInterface(name, host)` two-argument overload end-to-end
Similarly, `isYggdrasilHost` itself has no unit tests despite containing non-trivial IPv6 parsing logic (bracket stripping, port extraction, hex range check).
How can I resolve this? If you propose a fix, please make it concise.Reviews (10): Last reviewed commit: "fix: use null-separator ID and derived S..." | Re-trigger Greptile |
Adds a Room table (interface_first_seen) that records when each interface was first discovered. Uses INSERT OR IGNORE so the original timestamp is preserved across re-discoveries and app restarts. The first-seen timestamp is shown in the interface detail bottom sheet alongside last-heard and hop count. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Map pins now use the same icon identity as the discovery screen: - LoRa/RNode: MDI "antenna" (matches Lucide Antenna in discovery) - TCP: MDI "earth" (matches Icons.Default.Public globe in discovery) - I2P: MDI "incognito" (new category, matches discovery screen) - Yggdrasil: MDI "pine-tree" (new category, matches Lucide TreePine) - Also adds Weave/KISS to the LoRa category Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renders the exact same icons as the discovery screen: - LoRa: Lucide Antenna (was MDI "antenna" font glyph) - TCP: Lucide Globe (was MDI "earth") - BLE: Lucide Bluetooth (was MDI "bluetooth") - Auto: Lucide Wifi (was MDI "access-point") - Yggdrasil: Lucide TreePine (was MDI "pine-tree") - I2P: Lucide EyeOff (was MDI "incognito") MarkerBitmapFactory.createInterfaceMarker() now takes a drawable resource ID and renders it via VectorDrawable.draw(canvas) instead of font codepoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- TCP filter chip now uses Icons.Default.Public (globe with grid) matching the discovery screen's InterfaceTypeIcon - Retry initial interface marker load after 5s if service wasn't ready at init time (reduces delay before pins appear) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added ic_public_24.xml vector drawable (Material Design Public icon) so TCP map pins render the same globe-with-grid as the discovery screen's Icons.Default.Public, instead of the Lucide wireframe globe. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter chips now use painterResource(markerIconResId) instead of InterfaceCategory.icon, so all interface types render the same icon in both the filter chips and the map pins: - I2P: incognito hat+glasses (local drawable from MDI path data) - Yggdrasil: Lucide TreePine - TCP: Material Public globe (local drawable) - LoRa: Lucide Antenna - BLE: Lucide Bluetooth - Auto: Lucide Wifi Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous hand-written SVG path was incorrect. Extracted the actual path data from the MDI TTF font using fonttools, with proper 512x512 viewport and Y-axis flip to match Android's coordinate system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
formatTimeAgo() expects Unix seconds (divides currentTimeMillis by 1000 internally). firstSeenTimestamp was stored in milliseconds, causing the diff to be negative and always showing "Just now". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
RNS persists all other interface discovery data (last heard, hops, coordinates, radio params) in msgpack files under ~/.reticulum/storage/discovery/interfaces/. First-seen is the only field not tracked by RNS, hence the Room table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ffset - Update InterfaceInfoTest to expect Icons.Default.Public (was Cloud) - Merge duplicate ID computation loops into single withId pass - Remove unused getFirstSeen() DAO method - Fix filter chip top padding to 64dp (M3 TopAppBar height) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace dash-separated interface ID with null-byte separator to prevent collisions when name/type/host contain dashes - Convert filteredInterfaceMarkers from computed property to derived StateFlow for proper Compose observation and testability - MapScreen collects it via collectAsState() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Discovered network interfaces with GPS coordinates are now rendered as pins on the map, giving users a spatial view of network infrastructure.
FocusInterfaceBottomSheet— frequency, bandwidth, SF, coding rate for RNodes; host:port for TCP)Architecture
InterfaceCategoryenum made public withmdiIconNameandmarkerColorpropertiesInterfaceMarkerdata class inMapViewModelwithtoFocusInterfaceDetails()mapperMarkerBitmapFactory.createInterfaceMarker()— rounded rect with MDI icon, cached per categoryinterface-markers-layer) below contact markersFocusInterfaceBottomSheetandFocusInterfaceContentTest plan
🤖 Generated with Claude Code