Skip to content

feat: render discovered interfaces as map pins with type filtering#729

Merged
torlando-tech merged 14 commits intomainfrom
feat/interface-map-pins
Mar 30, 2026
Merged

feat: render discovered interfaces as map pins with type filtering#729
torlando-tech merged 14 commits intomainfrom
feat/interface-map-pins

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

Discovered network interfaces with GPS coordinates are now rendered as pins on the map, giving users a spatial view of network infrastructure.

  • Type-specific icons: LoRa/RNode (orange antenna), TCP (blue cloud), BLE (indigo bluetooth), Auto (green wifi)
  • Rounded-rectangle markers visually distinct from circular contact markers
  • Filter chips below the TopAppBar to toggle interface types on/off
  • Tap to inspect: bottom sheet shows full interface details (reuses existing FocusInterfaceBottomSheet — frequency, bandwidth, SF, coding rate for RNodes; host:port for TCP)
  • 30-second refresh alongside existing contact marker refresh

Architecture

  • InterfaceCategory enum made public with mdiIconName and markerColor properties
  • InterfaceMarker data class in MapViewModel with toFocusInterfaceDetails() mapper
  • MarkerBitmapFactory.createInterfaceMarker() — rounded rect with MDI icon, cached per category
  • Separate MapLibre layer (interface-markers-layer) below contact markers
  • No new files — reuses existing FocusInterfaceBottomSheet and FocusInterfaceContent

Test plan

  • All unit tests pass (MapViewModelTest, full suite)
  • detekt passes
  • Build succeeds
  • Open map with discovered interfaces that have coordinates → see type-specific pins
  • Tap an interface pin → bottom sheet shows details
  • Toggle filter chips → pins show/hide by type

🤖 Generated with Claude Code

torlando-tech and others added 2 commits March 29, 2026 12:01
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>
@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 29, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This 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 InterfaceMarker data class and filteredInterfaceMarkers StateFlow sit alongside the existing ContactMarker pipeline, a new MapLibre symbol layer (interface-markers-layer) is added below the contact layer, and the existing FocusInterfaceBottomSheet is reused for the detail view. A new Room table (interface_first_seen, version 44) persists first-seen timestamps not tracked by RNS itself.

Key findings:

  • Null-byte separator in composite interface ID (MapViewModel.kt): \"\\u0000\" is used as a delimiter between name, type, and reachableOn. While SQLite TEXT handles embedded nulls, this makes the key invisible in logs/debuggers and creates a theoretical collision risk. A printable separator like \"|\" would be safer.
  • isYggdrasilHost covers only the old Yggdrasil address space (InterfaceInfo.kt): The range check 0x0200..0x03FF matches the pre-v0.5 200::/7 prefix. Yggdrasil v0.5+ uses fc00::/8; TCP interfaces on those addresses would be categorised as plain TCP instead of YGGDRASIL.
  • UNKNOWN category filter chip has an empty label (InterfaceInfo.kt): defaultText = \"\" means any UNKNOWN interface with GPS coordinates renders a chip with just an icon and no text.
  • No unit tests for new categories (InterfaceInfoTest.kt): I2P, Yggdrasil, KISS, and Weave are new but untested; isYggdrasilHost's IPv6 parsing logic also has no coverage.

Confidence Score: 5/5

Safe 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

Filename Overview
app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt Adds InterfaceMarker data class, loadInterfaceMarkers() with DB persistence, filteredInterfaceMarkers StateFlow, and toggleInterfaceFilter; null-byte separator in composite ID is fragile
app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt Makes InterfaceCategory public, adds markerIconResId/markerColor properties, new I2P/YGGDRASIL/KISS/WEAVE categories, and isYggdrasilHost() heuristic that only covers old Yggdrasil address space
app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt Adds interface marker layer rendering, click-to-inspect flow, filter chips below TopAppBar, and reuses FocusInterfaceBottomSheet — logic is clean with no identified bugs
app/src/main/java/com/lxmf/messenger/ui/util/MarkerBitmapFactory.kt Adds createInterfaceMarker() that draws a rounded-rect with a tinted vector icon; caching correctly delegated to the MapLibre style image registry
data/src/main/java/com/lxmf/messenger/data/db/ColumbaDatabase.kt Bumps schema version to 44 and registers InterfaceFirstSeenEntity; migration SQL and DAO wiring are correct
data/src/main/java/com/lxmf/messenger/data/db/entity/InterfaceFirstSeenEntity.kt Minimal Room entity with PrimaryKey interfaceId and firstSeenTimestamp; well-documented
data/src/main/java/com/lxmf/messenger/data/db/dao/InterfaceFirstSeenDao.kt New DAO with IGNORE conflict strategy on insert and batch-fetch query; straightforward and correct
app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelTest.kt Injects mock reticulumProtocol and interfaceFirstSeenDao into all existing tests; stubs return empty results so no existing tests are affected
app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt Updates TCP icon assertions from Cloud to Public; no new tests for I2P, Yggdrasil, KISS, or Weave categories

Sequence Diagram

sequenceDiagram
    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
Loading
Prompt To Fix All With AI
This 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

torlando-tech and others added 12 commits March 29, 2026 14:50
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>
@torlando-tech torlando-tech merged commit 2e0e258 into main Mar 30, 2026
14 checks passed
@torlando-tech torlando-tech deleted the feat/interface-map-pins branch March 30, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant