Skip to content

feat: NomadNet browser with Micron rendering, URL bar, and URI handler#671

Merged
torlando-tech merged 39 commits intomainfrom
feature/nomadnet
Mar 13, 2026
Merged

feat: NomadNet browser with Micron rendering, URL bar, and URI handler#671
torlando-tech merged 39 commits intomainfrom
feature/nomadnet

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • NomadNet page browser — browse NomadNet nodes via Reticulum with full Micron markup rendering (text styling, colors, links, forms, ASCII art, partial auto-refresh)
  • Micron parser module — standalone parser for NomadNet's Micron markup language with comprehensive test coverage (878-line test suite)
  • Editable URL bar — tap the address bar to navigate to any hash:/path, with Copy URL and Share menu items
  • nomadnetwork:// URI handler — external apps can open NomadNet pages or LXMF conversations via deep links
  • Custom scheme linkificationnomadnetwork:// and lxma:// URLs are now clickable in conversation messages
  • Page caching — file-based cache with configurable TTL for previously visited pages
  • Pinch-to-zoom — layout-aware scaling in monospace scroll mode
  • Identify to node — reveal identity to node operators for authenticated pages
  • Partial support — async loading and auto-refresh of page partials

Test plan

  • Unit tests: 617 lines covering ViewModel (URL helpers, navigation, lxmf@ routing, history, forms, identify)
  • Unit tests: 878 lines covering Micron parser (styling, colors, links, fields, alignment, edge cases)
  • Unit tests: NomadNetPageCache and URL resolution
  • Manual: open NomadNet browser, verify URL bar shows hash:/path
  • Manual: tap URL bar, edit, press Go — navigates to new page
  • Manual: overflow menu Copy URL / Share works
  • Manual: adb shell am start -a android.intent.action.VIEW -d "nomadnetwork://hash:/page/index.mu" opens browser
  • Manual: nomadnetwork:// links in conversations are clickable and open browser
  • Manual: lxmf@hash links on Micron pages open conversation screen

🤖 Generated with Claude Code

torlando-tech and others added 17 commits March 12, 2026 12:17
Implement a native NomadNet page browser allowing users to connect to
nomadnetwork.node destinations and browse their Micron-formatted pages
directly from Android.

Phase 1 - Micron parser (:micron module):
- Pure Kotlin parser ported from NomadNet's MicronParser.py
- Handles headings, dividers, colors, formatting, links, form fields
- 69 unit tests covering all markup features

Phase 2 - Python page download:
- request_nomadnet_page() in reticulum_wrapper.py
- Separate link cache for nomadnetwork.node (distinct from lxmf.delivery)
- Identity discovery via path request for stale announces
- Cached active links reused without re-resolving identity

Phase 3 - AIDL/Service plumbing:
- requestNomadnetPage + cancelNomadnetPageRequest in AIDL
- Binder and ServiceReticulumProtocol implementations

Phase 4 - Browser UI:
- NomadNetBrowserViewModel with history stack, form state, rendering modes
- NomadNetBrowserScreen with loading/error/loaded states, pull-to-refresh
- MicronComposables for native Compose rendering of Micron markup
- Three rendering modes: monospace+scroll, monospace+zoom, proportional+wrap
- "Browse Node" button on nomadnetwork.node announce detail screens
- Navigation route wired in MainActivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix NomadNet URL parsing: handle colon-separated format (`:path` for
  same-node, `hash:path` for cross-node) which is the standard format
  used in Micron page links
- Add Python-side path sanitization as defense in depth
- Fix identity_hash in announce events to use actual identity hash
  instead of incorrectly copying destination_hash
- Switch to absolute deadline-based timeouts for link establishment
- Add comprehensive diagnostic logging: destination hash verification,
  hop count, RNS establishment timeout, teardown reason
- Fix misleading "Connection rejected" error — now correctly reports
  timeout vs actual node closure based on RNS teardown_reason
- Always request fresh path before link establishment to avoid stale
  routing info

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cache pages in cacheDir/nomadnet_pages/ with TTL from #!c=N directive
(default 12h, 0=never cache). Enables instant back-navigation via
in-memory document history and cache-first loading for revisited pages.
Form submissions always bypass cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use a regular Column with verticalScroll + horizontalScroll for
MONOSPACE_SCROLL mode instead of LazyColumn with per-line items,
so swiping horizontally moves all lines as one unit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… mode

Two-finger pinch gestures scale the page content (0.5x–3x) using a
layout modifier that adjusts both the visual rendering and the reported
dimensions, so zooming out shows more content and zooming in expands
the scrollable area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BoxWithConstraints captures viewport width before horizontalScroll
unbounds constraints. Centered/right-aligned lines use exact viewport
width (wrapping long text like NomadNet terminal), while left-aligned
lines use min-width so they can extend for horizontal scrolling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Backtick-prefixed links (`[label`dest]) and fields (`<|name`val>) had the
backtick output as literal text instead of being consumed as a formatting
mode entry. This caused stray backtick characters in rendered NomadNet pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow users to opt-in identify themselves to a NomadNet node by sending
their public key via link.identify(). This enables nodes to serve
personalized content or grant access-controlled pages.

Full-stack implementation through all 7 layers:
- Python: rns_api.py identify_nomadnet_link() (Strangler Fig pattern)
- Kotlin: PythonWrapperManager, AIDL, Binder, ServiceReticulumProtocol
- ViewModel: state tracking with auto-reset on node change
- UI: fingerprint icon in top bar + overflow menu with snackbar errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A bare 32-char destination hash (no colon/path) was falling through to
the else branch, treating it as a path on the current node — which caused
the Python layer to default back to /page/index.mu on the same node,
appearing as a "reload." Now recognized as cross-node with default path.

Also makes hex validation case-insensitive in both hash:path and bare
hash branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement NomadNet's partial markup (`{url`refresh`fields}) for
inline async-loaded content panels. Partials are fetched from remote
nodes, parsed as nested Micron documents, and rendered inline.

- Parse block-level `{ }` syntax with URL, refresh interval, fields, pid
- PartialManager orchestrates async loading with Semaphore(2) concurrency
- Auto-refresh via configurable interval; p:<pid> links trigger reload
- Extract shared resolveNomadNetUrl() eliminating duplicated URL parsing
- Fix angle brackets without backtick being silently dropped by parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show an AlertDialog before revealing identity to the node operator.
After successful identification, automatically refresh the page so
the node can serve identity-aware content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…on behavior)

The Kotlin parser was emitting backticks literally for unrecognized
formatting commands (e.g. backtick-space), while the Python reference
silently discards them. This caused stray backticks after links in
styled NomadNet pages like `[Send`dest]` .

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
canGoBack was a plain Boolean getter on a MutableList, invisible to
Compose recomposition. BackHandler(enabled=...) read false once and
never updated, so the system back always exited the browser.

Replace with a StateFlow<Boolean> updated on history push/pop and
collected via collectAsState() so BackHandler re-enables reactively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In scroll mode, disable softWrap and use widthIn(min=) instead of
width() for all alignments. The fixed width() on centered/right lines
was capping max width to the viewport, silently clipping wide content
even though horizontalScroll was enabled on the parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Modifier.layout with TextStyle.lineHeight for half-block pixel
height so SpanStyle.background fills the full line (no transparent gaps).
Bundle JetBrains Mono NL (Apache 2.0) so block elements (▄ █) have the
same advance width as ASCII, fixing jagged row widths in pixel art with
embedded text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…esolution

53 tests covering:
- ResolveNomadNetUrlTest (15): URL parsing for all link formats
- NomadNetPageCacheTest (14): file cache with TTL, expiration, content preservation
- NomadNetBrowserViewModelTest (23): navigation state machine, history, forms, identify

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ection

Add browser-style address bar to NomadNet browser that displays the
current page URL (hash:/path) in a tappable rounded field. Tapping
opens the keyboard for editing; pressing Go navigates to the new URL.
Overflow menu gains Copy URL and Share items.

Register nomadnetwork:// as an Android intent-filter scheme so external
apps and shared links can open NomadNet pages or start LXMF conversations
(nomadnetwork://lxmf@hash). The intent handler uses raw string parsing
to avoid Android's URI parser misinterpreting the colon in hash:/path.

Detect lxmf@ links in Micron pages and emit a NavigationEvent so the
browser screen can navigate to the conversation view.

Extend conversation message linkification to recognize nomadnetwork://
and lxma:// URIs alongside standard http(s) URLs, making shared NomadNet
links clickable in chat.

Move ktlint PostToolUse hook to git pre-commit to avoid stripping
imports between multi-step edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR adds a full NomadNet page browser to the app: a standalone micron module with a well-tested Micron markup parser, a NomadNetBrowserViewModel with caching, history, form submission, partial management, and identify support, a new browser UI screen with URL bar, pull-to-refresh, and pinch-to-zoom, plus nomadnetwork:// deep-link handling and custom-scheme linkification in conversation messages. The Python layer adds rns_api.py methods for establishing/reusing RNS links, fetching pages, and identifying to nodes.

Key improvements compared to the initial version (many prior thread issues addressed):

  • PartialManager.jobs upgraded to ConcurrentHashMap; _states uses .update {} for atomic writes; detectAndLoad uses putIfAbsent/replace to eliminate the TOCTOU duplicate-job race
  • NomadNetBrowserViewModel fields currentNodeHash and fetchEpoch are now @Volatile; emptyPartialStates is eagerly computed; onCleared() cancels any in-flight Python request; retry() correctly preserves form data; isPullRefreshing is properly wired to PullToRefreshBox
  • pageBackground/pageForeground forwarded to per-line MicronDocument in the LazyColumn path, so page-level background colors render correctly in scroll modes
  • zoomScale is keyed on page identity (remember(currentPage)) so it resets on navigation
  • Exception messages in PythonWrapperManager / ReticulumServiceBinder use JSONObject.quote() for safe JSON encoding
  • resolveAtSyntax now calls .lowercase() on both return branches
  • nomadnetwork://lxmf@hash deep links correctly navigate to PendingNavigation.Conversation instead of silently failing through AddContact

Remaining new issues found in this review pass:

  • PartialManager.buildFormDataJson() forwards all current form fields to every partial request, ignoring the fieldNames declared per-partial. This can send masked (password) field values to partial endpoints that did not declare those fields as dependencies, which is both a spec deviation and a potential data-exposure concern.
  • MicronPageContent is called once per line inside the LazyColumn (used by MONOSPACE_ZOOM and PROPORTIONAL_WRAP modes), causing N redundant rememberTextMeasurer() allocations and text-measurement computations even though squareLineHeightSp is TextUnit.Unspecified (unused) in those modes.

Confidence Score: 3/5

  • This PR is nearly merge-ready — the bulk of previously flagged races and correctness issues have been resolved, but one new logic issue (masked form fields forwarded to partial endpoints) warrants a fix before ship.
  • The PR is a large, well-structured addition (5,400+ lines across 35 files) with solid unit-test coverage (1,495 test lines). Most of the significant issues raised in earlier review rounds — thread safety, JSON injection, volatile fields, deep-link routing, and retry correctness — have all been addressed. The two remaining new issues are: (1) a logic bug in PartialManager.buildFormDataJson() that leaks all form fields (including masked password fields) to partial endpoints that didn't declare them, and (2) a performance issue with redundant TextMeasurer allocations in the LazyColumn path. The first is a correctness/security concern worth fixing; the second is a style improvement. The link_established.wait() cancel-polling gap noted in prior threads is still present in rns_api.py but is bounded and pre-existing. Overall the PR is in good shape with one concrete issue to address.
  • app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt (buildFormDataJson field filtering) and app/src/main/java/com/lxmf/messenger/ui/components/MicronComposables.kt (per-item TextMeasurer allocation).

Important Files Changed

Filename Overview
app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt 510-line ViewModel wiring page loading, caching, form submission, partial management, history, identify, and cancel. Many previously flagged races and thread-safety issues (volatile fields, fetchEpoch guard, retry with form data, onCleared cancel) are now properly addressed. Remaining concern: cache-hit path in loadPage/navigateToLink still executes pageCache.get() and MicronParser.parse() synchronously on the calling thread (main dispatcher), as noted in earlier review rounds.
app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt Manages async/auto-refresh partial fetches with ConcurrentHashMap for jobs, update{} for StateFlow atomicity, and putIfAbsent/replace for TOCTOU-safe job registration. New issue: buildFormDataJson() sends ALL current form fields to every partial endpoint, ignoring the fieldNames list declared by each partial — masked (password) fields will be forwarded to partial URLs that didn't declare them.
app/src/main/java/com/lxmf/messenger/ui/screens/NomadNetBrowserScreen.kt Full browser UI with URL bar, pull-to-refresh (isPullRefreshing wired correctly), zoom keyed on page identity, and per-line LazyColumn for MONOSPACE_ZOOM/PROPORTIONAL_WRAP. isPullRefreshing properly tied to ViewModel state. pageBackground/pageForeground now forwarded to per-line MicronDocument instances in LazyColumn. Minor: each LazyColumn item instantiates its own MicronPageContent, causing redundant TextMeasurer allocations in non-scroll modes.
app/src/main/java/com/lxmf/messenger/ui/components/MicronComposables.kt Renders parsed MicronDocument as Compose UI — handles text styling, links, forms (fields/checkboxes/radio), partials, headings, and pixel-art half-block rendering. Link annotations now use \u001F separator (fixing the prior
micron/src/main/java/com/lxmf/messenger/micron/MicronParser.kt New standalone Micron markup parser ported from NomadNet's reference Python implementation. Handles headings, dividers, inline formatting, links, fields (text/checkbox/radio), partials, literal mode, and page directives. Pure function with no shared mutable state; 878-line test suite covers styling, colors, links, fields, and edge cases. Looks correct and well-tested.
python/rns_api.py New Python API for NomadNet page browsing via RNS links. Properly bounds _identified_links with deque(maxlen=200). Link cache capped at MAX_CACHED_LINKS=8 with FIFO eviction and teardown of evicted links. _send_page_request correctly polls _cancel_flag in a loop. Remaining concern (from earlier threads): link_established.wait(timeout=link_wait) in _establish_link blocks up to ~40s without polling the cancel flag.
app/src/main/java/com/lxmf/messenger/nomadnet/NomadNetPageCache.kt File-based page cache keyed by SHA-256(nodeHash:path) with TTL encoded in filename. cleanExpired() moved to a background Thread in init. Minor unaddressed race: concurrent put() calls for the same key can leave orphaned files (removeByKey + writeText is not atomic), though this is benign — the extra file is cleaned up by cleanExpired().
app/src/main/java/com/lxmf/messenger/ui/screens/MessagingScreen.kt Adds custom scheme linkification for nomadnetwork:// and lxma:// URIs alongside standard Patterns.WEB_URL. Regex [^\s,;!?)\]]+(?<![.,;]) correctly includes ':' (preserving hash:/path separator) and '.' (preserving .mu extensions), with trailing-punctuation stripped via lookbehind. Overlap deduplication with WEB_URL matches is handled correctly.
app/src/main/java/com/lxmf/messenger/MainActivityIntentHandler.kt nomadnetwork:// intent handling: lxmf@ sub-links now correctly route to PendingNavigation.Conversation (direct conversation open) rather than the broken AddContact path from the initial version. Hash-only and hash:/path URIs correctly navigate to NomadNetBrowser.

Sequence Diagram

sequenceDiagram
    participant UI as NomadNetBrowserScreen
    participant VM as NomadNetBrowserViewModel
    participant Cache as NomadNetPageCache
    participant PM as PartialManager
    participant SRP as ServiceReticulumProtocol
    participant Py as rns_api.py (Python)

    UI->>VM: loadPage(nodeHash, path)
    VM->>Cache: get(nodeHash, path)
    alt Cache hit
        Cache-->>VM: markup string
        VM->>VM: MicronParser.parse(markup)
        VM->>PM: detectAndLoad(document)
    else Cache miss
        VM->>SRP: requestNomadnetPage(hash, path)
        SRP->>Py: request_nomadnet_page(bytes, path)
        Py-->>SRP: {success, content, path}
        SRP-->>VM: Result<NomadnetPageResult>
        VM->>Cache: put(nodeHash, path, content, cacheTime)
        VM->>VM: MicronParser.parse(content)
        VM->>PM: detectAndLoad(document)
    end
    VM-->>UI: BrowserState.PageLoaded

    PM->>SRP: requestNomadnetPage(partialUrl) [per partial]
    SRP->>Py: request_nomadnet_page(bytes, partialPath)
    Py-->>SRP: {success, content}
    SRP-->>PM: Result<NomadnetPageResult>
    PM-->>UI: partialStates update

    UI->>VM: cancelLoading()
    VM->>SRP: cancelNomadnetPageRequest()
    SRP->>Py: cancel_nomadnet_page_request()

    UI->>VM: identifyToNode()
    VM->>SRP: identifyNomadnetLink(nodeHash)
    SRP->>Py: identify_nomadnet_link(bytes)
    Py-->>SRP: {success, already_identified}
    SRP-->>VM: Result<Boolean>
    VM->>VM: refresh() [if !alreadyIdentified]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt
Line: 282-290

Comment:
**Masked form fields forwarded to partial endpoints that didn't declare them**

`buildFormDataJson()` serialises every key/value in `_formFields` and attaches it to every partial request. `MicronElement.Partial.fieldNames` records which specific form fields that partial depends on, but this list is never consulted here — it is stored in `PartialState` and otherwise ignored.

Concretely: if a page has a password field (`!|password`) alongside a partial that only declares `fieldNames = ["search"]`, the partial request will include the plaintext password value alongside the search query. The partial endpoint (which may be on a different path or even a different node) receives data it never asked for.

Per NomadNet's reference implementation (`Browser.py`), only the fields declared in the partial definition should be forwarded.

The fix is to thread `partial.fieldNames` through `loadPartial`/`fetchAndUpdate` and filter the map:

```kotlin
private fun buildFormDataJson(allowedFields: List<String>): String? {
    val fields = formFields()
    if (fields.isEmpty()) return null
    val filtered = if (allowedFields.isEmpty()) fields
                   else fields.filterKeys { it in allowedFields }
    if (filtered.isEmpty()) return null
    val json = JSONObject()
    for ((k, v) in filtered) json.put(k, v)
    return json.toString()
}
```

Then update `fetchAndUpdate` to accept and pass through the `fieldNames` from the partial.

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/components/MicronComposables.kt
Line: 85-96

Comment:
**Redundant `TextMeasurer` allocations in `LazyColumn` path**

`MicronPageContent` is called once per line item in the `LazyColumn` branch of `NomadNetBrowserScreen` (i.e. `MONOSPACE_ZOOM` and `PROPORTIONAL_WRAP` modes). Each call lands here and runs:
1. `rememberTextMeasurer()` — a composable that allocates a `TextMeasurer` slot per item
2. `remember(density) { textMeasurer.measure(...) }` — actually performs the measurement

But `squareLineHeightSp` is only used when `renderingMode == RenderingMode.MONOSPACE_SCROLL`, and that mode never uses `LazyColumn`. So for a 200-line page in `PROPORTIONAL_WRAP` mode, 200 `TextMeasurer` objects are allocated and 200 measurements are computed, all of which are discarded (the branch below assigns `TextUnit.Unspecified`).

Consider hoisting the measurement above the `LazyColumn` in `NomadNetBrowserScreen` and passing `squareLineHeightSp` directly as a parameter to `MicronPageContent` / `MicronLineComposable`, or guard the `remember` block:

```kotlin
val measuredLineHeightSp =
    if (renderingMode == RenderingMode.MONOSPACE_SCROLL) {
        remember(density) {
            val result = textMeasurer.measure(
                AnnotatedString("A"),
                TextStyle(fontFamily = JetBrainsMonoFamily, fontSize = 14.sp),
            )
            with(density) { (result.size.width * 2).toDp().toSp() }
        }
    } else {
        TextUnit.Unspecified
    }
```

This avoids computing the measurement entirely in modes that don't use it, reducing wasted work for each recycled `LazyColumn` item.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 05111b4

…owser PR

- Fix all detekt violations in app and micron modules (LongMethod, ReturnCount,
  CyclomaticComplexMethod, LoopWithTooManyJumpStatements, TooManyFunctions,
  UseCheckOrError, UnusedParameter, NoRelaxedMocks, NoVerifyOnlyTests)
- Fix race condition in PartialManager: use atomic StateFlow.update{} instead
  of non-atomic read-modify-write pattern
- Fix JSON string injection in error handlers via JSONObject.quote()
- Fix retry button targeting initial destination instead of failed URL
- Remove unused isDark and fontFamily parameters from MicronComposables
- Restructure NomadNetPageCache.get() and PartialManager URL resolution to
  reduce return count
- Add proper @Suppress annotations for parser functions with inherent complexity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 13, 2026

removeLast() resolves to Java 21's SequencedCollection.removeLast()
when compiled with JDK 21, causing NoSuchMethodError on CI (JDK 17).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +181 to +183
val document = MicronParser.parse(cached)
emitPageLoaded(document, path, destinationHash)
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MicronParser.parse called on the main thread for cache hits

loadPage is invoked from a LaunchedEffect which runs on the main dispatcher by default. When a cache hit is found, MicronParser.parse(cached) is called synchronously on the main thread (line 181), and the same pattern exists in navigateToLink's cached-page branch (line 253) which is called directly from Compose's onLinkClick.

For typical small NomadNet pages this is fine, but a large page with many lines could block the composition thread long enough to cause a visible jank frame.

Consider wrapping the parse in withContext(Dispatchers.Default):

val document = withContext(Dispatchers.Default) {
    MicronParser.parse(cached)
}
emitPageLoaded(document, path, destinationHash)

(This requires making loadPage a suspend function, or performing the work inside a viewModelScope.launch.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt
Line: 181-183

Comment:
**`MicronParser.parse` called on the main thread for cache hits**

`loadPage` is invoked from a `LaunchedEffect` which runs on the main dispatcher by default. When a cache hit is found, `MicronParser.parse(cached)` is called synchronously on the main thread (line 181), and the same pattern exists in `navigateToLink`'s cached-page branch (line 253) which is called directly from Compose's `onLinkClick`.

For typical small NomadNet pages this is fine, but a large page with many lines could block the composition thread long enough to cause a visible jank frame.

Consider wrapping the parse in `withContext(Dispatchers.Default)`:
```kotlin
val document = withContext(Dispatchers.Default) {
    MicronParser.parse(cached)
}
emitPageLoaded(document, path, destinationHash)
```
(This requires making `loadPage` a `suspend` function, or performing the work inside a `viewModelScope.launch`.)

How can I resolve this? If you propose a fix, please make it concise.

The micron module had 878 lines of tests but no JaCoCo plugin, so
codecov reported 0% patch coverage for all parser code. Add jacoco
plugin to generate XML reports, include :micron:test in CI module
tests job, and add micron/ to codecov flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Follow the strangler fig pattern: move request_nomadnet_page and
cancel_nomadnet_page_request from the 8000-line reticulum_wrapper.py
monolith to rns_api.py. Split the monolithic method into three focused
methods (_establish_link, _send_page_request, request_nomadnet_page).

Kotlin callers now go through PythonWrapperManager.requestNomadnetPage()
-> rns_api.py, matching the existing pattern for identifyNomadnetLink.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech and others added 2 commits March 12, 2026 23:23
- Add fetchEpoch counter to prevent stale cancel coroutines from
  poisoning subsequent retry requests. cancelLoading() captures the
  epoch before dispatching to IO; if a new fetch has started by the
  time it runs, the cancel is skipped.
- Add isPullRefreshing StateFlow so PullToRefreshBox spinner stays
  visible during refresh. The previous approach (checking
  BrowserState.Loading) couldn't work because the PullToRefreshBox
  is only composed in the PageLoaded branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lumn

- Replace '|' with '\u001F' (Unit Separator) as the annotation
  separator between link destination and field names, so multi-partial
  reload links like p:pid1|pid2 are not incorrectly split
- Forward pageBackground/pageForeground from the parent document when
  creating per-line MicronDocuments in LazyColumn rendering mode, so
  pages with #!bg= directives render correct backgrounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +192 to +197
val cached = pageCache.get(destinationHash, path)
if (cached != null) {
val document = MicronParser.parse(cached)
emitPageLoaded(document, path, destinationHash)
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking file I/O in loadPage on the main thread

pageCache.get() calls cacheDir.listFiles() (a blocking filesystem scan) and on a hit, file.readText() (a blocking read). Both loadPage and navigateToLink are non-suspend functions that run on the main thread — loadPage is called from a LaunchedEffect on the main dispatcher, and navigateToLink is called directly from onLinkClick (a Compose click callback). For a fully-warm cache with many entries, listFiles() alone can take several milliseconds, causing dropped frames.

Note: the MicronParser.parse call that immediately follows the cache read adds to the main-thread work. Both should be moved off together with withContext(Dispatchers.Default/IO). For example:

viewModelScope.launch {
    val cached = withContext(Dispatchers.IO) { pageCache.get(destinationHash, path) }
    if (cached != null) {
        val document = withContext(Dispatchers.Default) { MicronParser.parse(cached) }
        emitPageLoaded(document, path, destinationHash)
        return@launch
    }
    fetchPage(destinationHash, path, cacheResponse = true)
}

The same fix applies to the cache hit path in navigateToLink (lines 252–256).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt
Line: 192-197

Comment:
**Blocking file I/O in `loadPage` on the main thread**

`pageCache.get()` calls `cacheDir.listFiles()` (a blocking filesystem scan) and on a hit, `file.readText()` (a blocking read). Both `loadPage` and `navigateToLink` are non-suspend functions that run on the main thread — `loadPage` is called from a `LaunchedEffect` on the main dispatcher, and `navigateToLink` is called directly from `onLinkClick` (a Compose click callback). For a fully-warm cache with many entries, `listFiles()` alone can take several milliseconds, causing dropped frames.

Note: the `MicronParser.parse` call that immediately follows the cache read adds to the main-thread work. Both should be moved off together with `withContext(Dispatchers.Default/IO)`. For example:

```kotlin
viewModelScope.launch {
    val cached = withContext(Dispatchers.IO) { pageCache.get(destinationHash, path) }
    if (cached != null) {
        val document = withContext(Dispatchers.Default) { MicronParser.parse(cached) }
        emitPageLoaded(document, path, destinationHash)
        return@launch
    }
    fetchPage(destinationHash, path, cacheResponse = true)
}
```

The same fix applies to the cache hit path in `navigateToLink` (lines 252–256).

How can I resolve this? If you propose a fix, please make it concise.

…nk cache cap

- Fix checkbox checked-state using String.contains() (substring match)
  instead of split(",").contains() (list membership), which caused
  false positives when checkbox values share prefixes
- Move NomadNetPageCache.cleanExpired() to a background thread in init
  to avoid blocking the main thread with file I/O on startup
- Cap _nomadnet_links dict at MAX_IDENTIFIED_LINKS entries, tearing
  down the oldest link when evicting to release RNS resources

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +117 to +126
link = wrapper._nomadnet_links.get(dest_hash_hex)
if link is not None and link.status != RNS.Link.ACTIVE:
# Stale link, remove it
log_debug("RnsApi", "request_nomadnet_page",
f"Stale link to {dest_hash_hex[:16]}, will re-establish")
wrapper._nomadnet_links.pop(dest_hash_hex, None)
link = None

# Only recall identity and establish link if no cached active link
if link is not None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A race condition exists when evicting from the _nomadnet_links cache. Concurrent modifications can cause a RuntimeError because the dictionary's size is checked before it is iterated.
Severity: MEDIUM

Suggested Fix

Wrap the cache eviction logic in a threading.Lock. The lock should be acquired before checking len(wrapper._nomadnet_links) and released after the pop() operation to ensure the check-then-evict sequence is atomic and prevent race conditions with other threads.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: python/rns_api.py#L117-L126

Potential issue: Concurrent calls to `request_nomadnet_page()` can create a race
condition when the `_nomadnet_links` cache is full. The code checks the dictionary's
size and then gets an iterator to find an item to evict. If another thread modifies the
dictionary between the size check and the iteration, the operation can fail with a
`RuntimeError: dictionary changed size during iteration`. While the
`MAX_CONCURRENT_FETCHES` semaphore limits concurrency to two, and the race window is
very small, this could still lead to an unexpected crash under specific load conditions.

- Add @volatile to currentNodeHash for cross-thread visibility
- Fix TOCTOU race in detectAndLoad using ConcurrentHashMap.putIfAbsent
- Stop auto-refresh partials after 3 consecutive errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech and others added 2 commits March 13, 2026 00:26
- Reserve job slot with placeholder before launching coroutine, so
  clear() can never miss an in-flight job between launch and map insert
- Add MAX_CACHED_LINKS = 8 for live RNS.Link objects (was reusing
  MAX_IDENTIFIED_LINKS = 200, far too large for heavyweight objects)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace `if (...) continue` with `if (...) { ... }` guard to keep
the loop at one jump statement (the safe-cast `?: continue`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
*/
@Suppress("ReturnCount")
fun parse(colorStr: String): MicronColor? {
if (colorStr.length < 3) return null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where truecolor FT and BT :<

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the reminder!

Comment on lines +79 to +101
* - `0` → do not cache (returns immediately)
* - positive → cache for that many seconds
*/
fun put(
nodeHash: String,
path: String,
content: String,
cacheSeconds: Int?,
) {
val ttl = cacheSeconds ?: DEFAULT_CACHE_SECONDS
if (ttl <= 0) return

val key = cacheKey(nodeHash, path)

try {
cacheDir.mkdirs()

// Remove any existing entries for this key
removeByKey(key)

val expiresMs = System.currentTimeMillis() + ttl * 1000L
val file = File(cacheDir, "${key}_$expiresMs")
file.writeText(content)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-atomic removeByKey + writeText can leave orphaned cache files

put() is called from fetchPage on Dispatchers.IO. If two concurrent callers write the same (nodeHash, path) key — e.g. a rapid link re-tap while a fresh network fetch is landing — the removeByKey + writeText sequence races:

  1. Thread A: removeByKey(key) deletes the existing file
  2. Thread B: removeByKey(key) finds nothing (already gone)
  3. Thread A: writes "${key}_timestamp1"
  4. Thread B: writes "${key}_timestamp2"

Both files now exist for the same key. get() picks whichever the filesystem returns first via firstOrNull, leaving the other file as an orphan until cleanExpired() runs. Over time this can accumulate duplicate files for high-traffic keys.

Consider adding a per-operation lock (a Mutex keyed by the cache key, or a simple @Synchronized on the method since disk I/O is already the bottleneck) to make the remove-then-write atomic.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/nomadnet/NomadNetPageCache.kt
Line: 79-101

Comment:
**Non-atomic `removeByKey` + `writeText` can leave orphaned cache files**

`put()` is called from `fetchPage` on `Dispatchers.IO`. If two concurrent callers write the same `(nodeHash, path)` key — e.g. a rapid link re-tap while a fresh network fetch is landing — the `removeByKey` + `writeText` sequence races:

1. Thread A: `removeByKey(key)` deletes the existing file
2. Thread B: `removeByKey(key)` finds nothing (already gone)
3. Thread A: writes `"${key}_timestamp1"`
4. Thread B: writes `"${key}_timestamp2"`

Both files now exist for the same key. `get()` picks whichever the filesystem returns first via `firstOrNull`, leaving the other file as an orphan until `cleanExpired()` runs. Over time this can accumulate duplicate files for high-traffic keys.

Consider adding a per-operation lock (a `Mutex` keyed by the cache key, or a simple `@Synchronized` on the method since disk I/O is already the bottleneck) to make the remove-then-write atomic.

How can I resolve this? If you propose a fix, please make it concise.

The tests verified Log.e calls from a Dispatchers.IO coroutine using
advanceUntilIdle(), which only controls the test dispatcher. Use
verify(timeout = 1000) to wait for the IO thread to complete. Also
add missing 3-arg Log.e(String, String, Throwable) mock stub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Some-O-ne
Copy link
Copy Markdown

heeey torlando when truecolor color tags? :>

- fetchEpoch is read from IO threads; @volatile ensures visibility
- Replace blocking response_event.wait() with 0.5s polling loop that
  checks _cancel_flag, matching the pattern used in _establish_link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +175 to +178
jobs[key] =
scope.launch(Dispatchers.IO) {
fetchAndUpdate(key, existing.url, existing.refreshInterval)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The reloadPartial function's non-atomic job assignment can cause a race condition with clear(), leading to leaked coroutines and stale UI state updates during navigation.
Severity: MEDIUM

Suggested Fix

Make the job creation and assignment in reloadPartial atomic. Use a placeholder pattern similar to the one in detectAndLoad, where a temporary Job is placed in the map atomically using putIfAbsent before the real coroutine is launched and replaces it. This ensures that concurrent calls to clear() will correctly cancel the job.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt#L175-L178

Potential issue: A race condition exists in `PartialManager.reloadPartial`. The function
directly assigns a new job to the `jobs` map without an atomic operation. If a user
triggers a partial reload and then navigates away, the `clear()` function may be called
concurrently. If `clear()` runs after the old job is cancelled but before the new one is
assigned, the new job will be added to an already-cleared map. This "leaked" coroutine
will continue running and eventually update `_states` with stale data, potentially
causing the UI to display incorrect content from the previous page.

Replace blocking time.sleep(2s) with 0.25s polling loop so cancel
requests are responsive even during the existing-path fast path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Calling rememberTextMeasurer() inside an if-block violates Compose's
rules of remember — the slot table ordering must be stable across
recompositions. Hoisting the call ensures consistent ordering even
when renderingMode changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

heeey torlando when truecolor color tags? :>

Will have to get to it tomorrow. I'm gonna have to do another round of testing after all the automated iteration this went through anyway

torlando-tech and others added 2 commits March 13, 2026 01:43
…ility

Move the text measurement remember block unconditionally outside the
if-block so the slot table is stable across rendering mode changes.
The measurement is cheap (single character) and the result is only
applied in MONOSPACE_SCROLL mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrites the NomadNet page link establishment to match the NomadNet
TUI's proven path-first sequence (Browser.py __load): ensure path →
recall identity → create destination → establish link.

The old code did identity-first (recall identity → construct
destination → request path with recomputed hash → unconditional 2s
fresh-path wait). This caused failures because the path table starts
empty on every app restart for non-transport nodes.

Key changes:
- Path resolution uses the original destination hash throughout,
  never a recomputed one from Destination.hash
- Removed the unconditional 2s fresh-path wait that delayed every
  page load even when a valid path already existed
- Replaced single-shot RNS.Transport.await_path with a retry loop
  that re-sends request_path every 5s. RNS sends the path request
  packet exactly once with no retry — if the packet or response is
  lost, the path never arrives. The retry loop works around this.
- Cancel flag is polled every 0.25s during path wait

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +282 to +290
private fun buildFormDataJson(): String? {
val fields = formFields()
if (fields.isEmpty()) return null
val json = JSONObject()
for ((k, v) in fields) {
json.put(k, v)
}
return json.toString()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Masked form fields forwarded to partial endpoints that didn't declare them

buildFormDataJson() serialises every key/value in _formFields and attaches it to every partial request. MicronElement.Partial.fieldNames records which specific form fields that partial depends on, but this list is never consulted here — it is stored in PartialState and otherwise ignored.

Concretely: if a page has a password field (!|password) alongside a partial that only declares fieldNames = ["search"], the partial request will include the plaintext password value alongside the search query. The partial endpoint (which may be on a different path or even a different node) receives data it never asked for.

Per NomadNet's reference implementation (Browser.py), only the fields declared in the partial definition should be forwarded.

The fix is to thread partial.fieldNames through loadPartial/fetchAndUpdate and filter the map:

private fun buildFormDataJson(allowedFields: List<String>): String? {
    val fields = formFields()
    if (fields.isEmpty()) return null
    val filtered = if (allowedFields.isEmpty()) fields
                   else fields.filterKeys { it in allowedFields }
    if (filtered.isEmpty()) return null
    val json = JSONObject()
    for ((k, v) in filtered) json.put(k, v)
    return json.toString()
}

Then update fetchAndUpdate to accept and pass through the fieldNames from the partial.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/nomadnet/PartialManager.kt
Line: 282-290

Comment:
**Masked form fields forwarded to partial endpoints that didn't declare them**

`buildFormDataJson()` serialises every key/value in `_formFields` and attaches it to every partial request. `MicronElement.Partial.fieldNames` records which specific form fields that partial depends on, but this list is never consulted here — it is stored in `PartialState` and otherwise ignored.

Concretely: if a page has a password field (`!|password`) alongside a partial that only declares `fieldNames = ["search"]`, the partial request will include the plaintext password value alongside the search query. The partial endpoint (which may be on a different path or even a different node) receives data it never asked for.

Per NomadNet's reference implementation (`Browser.py`), only the fields declared in the partial definition should be forwarded.

The fix is to thread `partial.fieldNames` through `loadPartial`/`fetchAndUpdate` and filter the map:

```kotlin
private fun buildFormDataJson(allowedFields: List<String>): String? {
    val fields = formFields()
    if (fields.isEmpty()) return null
    val filtered = if (allowedFields.isEmpty()) fields
                   else fields.filterKeys { it in allowedFields }
    if (filtered.isEmpty()) return null
    val json = JSONObject()
    for ((k, v) in filtered) json.put(k, v)
    return json.toString()
}
```

Then update `fetchAndUpdate` to accept and pass through the `fieldNames` from the partial.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +85 to +96
val textMeasurer = rememberTextMeasurer()
val measuredLineHeightSp =
remember(density) {
val result =
textMeasurer.measure(
AnnotatedString("A"),
TextStyle(fontFamily = JetBrainsMonoFamily, fontSize = 14.sp),
)
with(density) { (result.size.width * 2).toDp().toSp() }
}
val squareLineHeightSp =
if (renderingMode == RenderingMode.MONOSPACE_SCROLL) measuredLineHeightSp else TextUnit.Unspecified
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant TextMeasurer allocations in LazyColumn path

MicronPageContent is called once per line item in the LazyColumn branch of NomadNetBrowserScreen (i.e. MONOSPACE_ZOOM and PROPORTIONAL_WRAP modes). Each call lands here and runs:

  1. rememberTextMeasurer() — a composable that allocates a TextMeasurer slot per item
  2. remember(density) { textMeasurer.measure(...) } — actually performs the measurement

But squareLineHeightSp is only used when renderingMode == RenderingMode.MONOSPACE_SCROLL, and that mode never uses LazyColumn. So for a 200-line page in PROPORTIONAL_WRAP mode, 200 TextMeasurer objects are allocated and 200 measurements are computed, all of which are discarded (the branch below assigns TextUnit.Unspecified).

Consider hoisting the measurement above the LazyColumn in NomadNetBrowserScreen and passing squareLineHeightSp directly as a parameter to MicronPageContent / MicronLineComposable, or guard the remember block:

val measuredLineHeightSp =
    if (renderingMode == RenderingMode.MONOSPACE_SCROLL) {
        remember(density) {
            val result = textMeasurer.measure(
                AnnotatedString("A"),
                TextStyle(fontFamily = JetBrainsMonoFamily, fontSize = 14.sp),
            )
            with(density) { (result.size.width * 2).toDp().toSp() }
        }
    } else {
        TextUnit.Unspecified
    }

This avoids computing the measurement entirely in modes that don't use it, reducing wasted work for each recycled LazyColumn item.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/src/main/java/com/lxmf/messenger/ui/components/MicronComposables.kt
Line: 85-96

Comment:
**Redundant `TextMeasurer` allocations in `LazyColumn` path**

`MicronPageContent` is called once per line item in the `LazyColumn` branch of `NomadNetBrowserScreen` (i.e. `MONOSPACE_ZOOM` and `PROPORTIONAL_WRAP` modes). Each call lands here and runs:
1. `rememberTextMeasurer()` — a composable that allocates a `TextMeasurer` slot per item
2. `remember(density) { textMeasurer.measure(...) }` — actually performs the measurement

But `squareLineHeightSp` is only used when `renderingMode == RenderingMode.MONOSPACE_SCROLL`, and that mode never uses `LazyColumn`. So for a 200-line page in `PROPORTIONAL_WRAP` mode, 200 `TextMeasurer` objects are allocated and 200 measurements are computed, all of which are discarded (the branch below assigns `TextUnit.Unspecified`).

Consider hoisting the measurement above the `LazyColumn` in `NomadNetBrowserScreen` and passing `squareLineHeightSp` directly as a parameter to `MicronPageContent` / `MicronLineComposable`, or guard the `remember` block:

```kotlin
val measuredLineHeightSp =
    if (renderingMode == RenderingMode.MONOSPACE_SCROLL) {
        remember(density) {
            val result = textMeasurer.measure(
                AnnotatedString("A"),
                TextStyle(fontFamily = JetBrainsMonoFamily, fontSize = 14.sp),
            )
            with(density) { (result.size.width * 2).toDp().toSp() }
        }
    } else {
        TextUnit.Unspecified
    }
```

This avoids computing the measurement entirely in modes that don't use it, reducing wasted work for each recycled `LazyColumn` item.

How can I resolve this? If you propose a fix, please make it concise.

@torlando-tech torlando-tech merged commit 7c7372c into main Mar 13, 2026
24 of 27 checks passed
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.

2 participants