Skip to content

feat: multiple firmware sources + custom firmware in RNode flasher (#485)#582

Merged
torlando-tech merged 18 commits intomainfrom
feat/multiple-firmware-sources
Mar 11, 2026
Merged

feat: multiple firmware sources + custom firmware in RNode flasher (#485)#582
torlando-tech merged 18 commits intomainfrom
feat/multiple-firmware-sources

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • Adds a FirmwareSource sealed class (Official, MicroReticulum, CommunityEdition, Custom) — closes After 0.9.0 release: Add microReticulum, Community Edition and custom RNode firmware flash capabilities #485
  • FirmwareDownloader is now parameterised by source; no hardcoded markqvist repo
  • FirmwareRepository uses per-source cache subdirectories (firmware/{official,ce,microreticulum,custom}/) so downloaded firmware from different repos never conflicts
  • RNodeFlasher.downloadAndFlash forwards the source to both the downloader and the repository
  • FlasherViewModel tracks the selected source in UI state; the Custom path reads a local file URI via ContentResolver or downloads from a user-supplied URL before flashing
  • New FirmwareSourceCard (four FilterChips) sits above the frequency-band picker in the firmware selection step; selecting Custom reveals a URL text field and a "Pick .zip file" button

Test plan

  • Launch flasher → Official is pre-selected; fetching versions and flashing works as before
  • Switch to Community Edition → versions load from liberatedsystems/RNode_Firmware_CE; cached firmware lands in firmware/ce/
  • Switch to microReticulum → versions load from attermann/microReticulum_Firmware
  • Select Custom → enter a direct .zip URL → tap "Start Flashing" → download progress shows and firmware is flashed
  • Select Custom → tap "Pick .zip file" → select a local zip → tap "Start Flashing" → firmware is flashed
  • Cache isolation: download Official v1.78, switch to CE → Official cached versions are not shown in the CE list
  • Run unit tests: ./gradlew :reticulum:testDebugUnitTest (all pass)

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 28, 2026

Greptile Summary

This PR adds support for multiple firmware sources (Official, microReticulum, Community Edition, Custom) in the RNode flasher, implements per-source cache isolation under firmware/{source-id}/, and introduces two new user-facing flows: post-flash TNC radio configuration for microReticulum devices and a standalone "Configure/Disable Transport" path from UsbDeviceActionScreen. The core logic is well-structured — FirmwareSource is a clean sealed class, FirmwareDownloader/FirmwareRepository correctly accept the source parameter, TncModeController properly handles structured concurrency (CancellationException rethrown in both methods), and the RNodeDetector TNC command sequence is backed by a comprehensive byte-level test suite. The previously flagged issues (wrong band in TNC-only mode, CancellationException swallowed in FlasherTncHelper, silent transport config error in ReviewConfigStep) have all been addressed.

Key observations:

  • RNodeFlasher bypasses DI in both RNodeWizardViewModel (via a lazy property) and in MainActivity (instantiated inline in a Compose rememberCoroutineScope), which breaks testability and is inconsistent with how FlasherViewModel receives RNodeFlasher via Hilt injection.
  • File picker MIME typepickFileLauncher.launch("*/*") accepts any file type despite the button being labelled "Pick .zip file"; users who accidentally pick a wrong file type will see an obscure flash error rather than immediate feedback.
  • The inline RNodeFlasher in MainActivity also creates an unobserved flashState flow, meaning any future changes that rely on intermediate state emissions from disableTncMode would silently stop working.

Confidence Score: 4/5

  • Safe to merge; remaining issues are testability and minor UX improvements, not correctness or data-loss risks.
  • The hardware-critical paths (KISS command sequence, per-source cache isolation, band derivation, structured-concurrency handling) are all correct and well-tested. The two flagged items — DI bypass for RNodeFlasher and the "*/*" file picker — are architectural/UX concerns that don't affect correctness of the flashing or TNC configuration paths.
  • app/src/main/java/com/lxmf/messenger/MainActivity.kt (inline RNodeFlasher construction) and app/src/main/java/com/lxmf/messenger/viewmodel/RNodeWizardViewModel.kt (same concern with the lazy property).

Important Files Changed

Filename Overview
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/TncModeController.kt New controller handling enable/disable TNC mode over USB; correctly rethrows CancellationException in both methods and disconnects the bridge on all exit paths.
reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/FirmwareDownloader.kt Parameterised by FirmwareSource; githubReleasesUrl now guards against Custom via requireNotNull; version getter correctly prefers release name over tag.
app/src/main/java/com/lxmf/messenger/viewmodel/FlasherTncHelper.kt New helper cleanly extracts TNC configuration and custom firmware logic from FlasherViewModel; CancellationException is handled correctly in the URI read path and source-availability loop.
app/src/main/java/com/lxmf/messenger/viewmodel/FlasherViewModel.kt Correctly adds TNC_CONFIGURATION step, derives frequency band from tncFrequencyMhz in applyTncConfiguration, and delegates to helper for all new operations.
app/src/main/java/com/lxmf/messenger/viewmodel/RNodeWizardViewModel.kt Adds transport mode flag, applyTransportMode correctly derives band from frequency value, and error is surfaced via AlertDialog in RNodeWizardScreen; flasher lazy bypasses DI which limits testability.
app/src/main/java/com/lxmf/messenger/MainActivity.kt Adds transport configure/disable navigation flows and new route arguments; RNodeFlasher is instantiated inline in a Compose coroutine scope for disableTncMode, bypassing DI and creating an unobserved flashState instance.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([User: Flash Firmware]) --> B[Device Selection]
    B --> C{Mode?}
    C -- tncConfigOnly --> D[TNC Configuration Step]
    C -- skipDetection --> E[Firmware Selection]
    C -- normal --> F[Device Detection]
    F --> E

    E --> G{Source?}
    G -- Official/CE/microReticulum --> H[Download from GitHub repo]
    G -- Custom URL --> I[Download from URL]
    G -- Custom File --> J[Read URI via ContentResolver]
    H --> K[Save to firmware/source-id/ cache]
    I --> K
    J --> K

    K --> L[Flash Firmware]
    L --> M{microReticulum?}
    M -- Yes --> D
    M -- No --> N[Complete Step]

    D --> O{Standalone?}
    O -- Yes --> P[tncConfigComplete = true → navigate back]
    O -- No --> N

    A2([User: Configure Transport]) --> B2[RNode Wizard - Transport Mode]
    B2 --> Q[Review Config Step - no interface name/airtime fields]
    Q --> R[applyTransportMode - TncModeController.enableTncMode]
    R --> S[AlertDialog on error / saveSuccess on OK]

    A3([User: Disable Transport]) --> T[Confirmation Dialog]
    T --> U[RNodeFlasher inline - TncModeController.disableTncMode]
    U --> V[Result Dialog]
Loading

Comments Outside Diff (3)

  1. app/src/main/java/com/lxmf/messenger/ui/screens/flasher/RNodeFlasherScreen.kt, line 404 (link)

    File picker accepts all file types

    The picker is launched with "*/*", which lets users select any file despite the button being labelled "Pick .zip file". Selecting a non-ZIP file will produce an obscure error during flashing rather than a clear up-front message.

    Using a more specific MIME type gives the system file picker a hint to filter for ZIP files:

    Note: some Android file managers don't tag local ZIP files with application/zip and instead use application/octet-stream. A common approach is to try "application/zip" first and fall back to "*/*" if needed, or accept both via an ActivityResultContracts.OpenDocument with multiple MIME types (arrayOf("application/zip", "application/octet-stream")). At minimum, matching the label is a good improvement over "*/*".

  2. app/src/main/java/com/lxmf/messenger/MainActivity.kt, line 42-48 (link)

    RNodeFlasher instantiated inline, bypassing dependency injection

    A fresh RNodeFlasher (and its inner KotlinUSBBridge, RNodeDetector, and FirmwareRepository) is constructed ad-hoc inside a Compose rememberCoroutineScope lambda every time the user taps "Disable Transport". This creates several concerns:

    1. DI inconsistency — everywhere else RNodeFlasher is a Hilt-injected singleton (in FlasherViewModel). Inline construction makes this code impossible to unit-test with a mocked bridge.
    2. Unobserved flashState — the newly created flasher.flashState is never collected, so any intermediate Progress emissions are silently discarded. Only the direct return value from disableTncMode is used, which happens to be fine today, but will silently break if disableTncMode is ever refactored to return before the final Complete emission.

    Consider moving disableTncMode into a dedicated ViewModel (similar to how applyTransportMode lives in RNodeWizardViewModel) and injecting RNodeFlasher or TncModeController via Hilt.

  3. app/src/main/java/com/lxmf/messenger/viewmodel/RNodeWizardViewModel.kt, line 3214-3217 (link)

    RNodeFlasher created without DI in ViewModel

    Similar to the inline construction in MainActivity, this lazy property bypasses Hilt. RNodeFlasher transitively constructs a KotlinUSBBridge, RNodeDetector, and FirmwareRepository — all of which are injected elsewhere via Hilt.

    Aside from the testability concern, if RNodeWizardViewModel ever needs to be tested in isolation (e.g. with a fake USB bridge), this hard-coded construction makes that impossible without refactoring.

    Consider adding a TncModeController (or the full RNodeFlasher) to the Hilt module and injecting it into this ViewModel, matching the pattern already used by FlasherViewModel.

Last reviewed commit: c3062a9

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

61 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 28, 2026

Additional Comments (1)

reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/FirmwareDownloader.kt
Missing URL validation before downloading arbitrary URLs - could accept malicious schemes like file:// or ftp://. Validate that the URL uses HTTP/HTTPS scheme and points to a .zip file before initiating the download.

Prompt To Fix With AI
This is a comment left during a code review.
Path: reticulum/src/main/java/com/lxmf/messenger/reticulum/flasher/FirmwareDownloader.kt
Line: 184-220

Comment:
Missing URL validation before downloading arbitrary URLs - could accept malicious schemes like `file://` or `ftp://`. Validate that the URL uses HTTP/HTTPS scheme and points to a .zip file before initiating the download.

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

@torlando-tech torlando-tech added this to the v0.10.0 milestone Mar 7, 2026
@torlando-tech torlando-tech force-pushed the feat/multiple-firmware-sources branch from 93656d2 to f18c8ea Compare March 10, 2026 04:46
@torlando-tech torlando-tech force-pushed the feat/multiple-firmware-sources branch from 2e5e32f to 59f8a98 Compare March 11, 2026 03:58
Comment on lines +336 to +346
val selectedSource =
if (currentState.selectedFirmwareSource in available) {
currentState.selectedFirmwareSource
} else {
FirmwareSource.Official
}
currentState.copy(
availableFirmwareSources = available,
selectedFirmwareSource = selectedSource,
)
}
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 fallback logic in checkAvailableSources can set selectedFirmwareSource to a source not in availableFirmwareSources, leading to a broken and unresponsive UI state.
Severity: HIGH

Suggested Fix

In the fallback logic within checkAvailableSources, instead of hardcoding the selectedSource to FirmwareSource.Official, set it to the first item in the available list (e.g., using available.firstOrNull()). This ensures the selected source is always consistent with the available options shown in the UI.

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/viewmodel/FlasherTncHelper.kt#L335-L346

Potential issue: In `checkAvailableSources`, if the currently selected firmware source
is no longer available, the code defaults the selection to `FirmwareSource.Official`.
However, it does not verify that `FirmwareSource.Official` is actually in the list of
`available` sources. This can create a state inconsistency where
`selectedFirmwareSource` is a value not present in `availableFirmwareSources`. As the UI
only renders options from `availableFirmwareSources`, the user will see a selected
source that has no corresponding UI element to interact with, blocking them from
changing the source or proceeding with flashing.

@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

torlando-tech and others added 9 commits March 11, 2026 12:32
…asher

Implements issue #485. The RNode flasher now supports four firmware sources:
- Official (markqvist/RNode_Firmware) — unchanged default behaviour
- microReticulum (attermann/microReticulum_Firmware)
- Community Edition (liberatedsystems/RNode_Firmware_CE)
- Custom — user-supplied local .zip file or direct download URL

Key changes:
- FirmwareSource sealed class maps each source to its GitHub owner/repo
- FirmwareDownloader.getAvailableReleases/getLatestRelease accept a source param
- FirmwareRepository uses per-source subdirectories (firmware/{source.id}/)
  for cache isolation; clearCache(source?) clears one or all sources
- RNodeFlasher.downloadAndFlash forwards source to downloader + repository
- FlasherViewModel adds selectedFirmwareSource, customFirmwareUri, customFirmwareUrl
  state; new selectFirmwareSource/setCustomFirmwareUri/setCustomFirmwareUrl helpers;
  startFlashing dispatches to flashCustomFirmware for the Custom path
- FirmwareSelectionStep adds FirmwareSourceCard (four FilterChips) above the
  frequency-band picker; CustomFirmwareCard replaces the version list when Custom
  is selected, showing a URL text field and a local-file picker button
- RNodeFlasherScreen wires a rememberLauncherForActivityResult(GetContent) file
  picker and passes the new callbacks to FirmwareSelectionStep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rd wrap

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

Instead of hardcoding which boards each source supports, query each
source's latest GitHub release to check if it has matching firmware
assets. Falls back to showing all sources if GitHub is unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Some repos (microReticulum) use a short tag like "1.85" but name the
release "1.85.9". Using the release name gives the correct version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add TNC configuration step for microReticulum devices that sends KISS
serial commands to configure radio parameters (frequency, bandwidth,
spreading factor, coding rate, TX power) after flashing.

Add "Configure Transport" option on USB device action screen for
standalone transport configuration without flashing. Uses tncConfigOnly
mode to skip detection/firmware selection and go directly to radio
parameter configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Match rnodeconf's initRadio() sequence by sending CMD_RADIO_STATE (0x06)
with RADIO_STATE_ON (0x01) after setting radio params and before saving
config. Also send CMD_RESET after save so the device reboots into
transport mode. Without the radio-on command, the config was saved but
the device never activated transport mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace raw text fields with region selection chips and modem preset
cards (reusing the same ModemPreset enum and FrequencyRegions from
the RNode wizard). Advanced settings section is available for manual
overrides. Apply button requires a region to be selected.

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

Route "Configure Transport" through the existing RNode wizard with a
transportMode flag, reusing region/modem preset/frequency slot selection
instead of a separate mini-wizard. In transport mode, the review step
hides interface-specific fields (name, airtime limits, interface mode,
framebuffer) and the final action sends KISS TNC commands instead of
saving an interface config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a 4th option to the USB device action screen that clears saved
TNC radio configuration and resets the device back to normal
host-controlled mode. Shows confirmation dialog before proceeding
and progress/result feedback dialogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
torlando-tech and others added 9 commits March 11, 2026 12:32
21 tests that mock KotlinUSBBridge, capture all serial writes, decode
KISS frames, and assert the exact command order, command bytes, and
payload contents for enableTncMode and disableTncMode on RNodeDetector.

Verifies the sequence matches rnodeconf's --tnc implementation:
  enable: FREQ → BW → TXP → SF → CR → RADIO_STATE(ON) → CONF_SAVE → RESET
  disable: CONF_DELETE

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

Extract TNC mode operations from RNodeFlasher into TncModeController and
TNC/source/custom-firmware logic from FlasherViewModel into FlasherTncHelper.
This brings both classes under detekt's 600 LOC threshold. Also fixes
ImplicitDefaultLocale and ComplexCondition detekt violations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Avoids incrementing the NoRelaxedMocks suppression count tracked by CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The release name may be descriptive (e.g. "RNode Firmware 1.78") rather
than a bare version. Prefer name when it starts with a digit, otherwise
fall back to tagName.

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

applyTncConfiguration() now derives FrequencyBand from tncFrequencyMhz
instead of reading selectedBand, which is never set when firmware selection
is skipped (TNC-only Configure Transport flow). Fixes 433 MHz devices
receiving the wrong band parameter.

Also adds requireNotNull guards in githubReleasesUrl() so passing
FirmwareSource.Custom fails fast instead of producing a malformed URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous check (firstOrNull()?.isDigit()) would accept "1.78 - Bug
fixes" as a version, which embeds spaces and text into the firmware
filename, breaking parseFirmwareFile's regex and the cache lookup.

Now requires the entire name to be digits and dots only before preferring
it over tagName.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
transportConfigError was set in state on failure but never rendered.
Add an AlertDialog in RNodeWizardScreen (matching the saveError pattern)
and a clearTransportConfigError() method in the ViewModel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
catch(Exception) was swallowing CancellationException, breaking
structured concurrency. Use separate catch blocks so cancellation
propagates and the USB bridge is still disconnected on cleanup.

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

Add CancellationException catch blocks in flashCustomFirmware() and
checkAvailableSources() to preserve structured concurrency (matching
TncModeController pattern).

Tighten version regex from [\d.]+ to \d+(\.\d+)+ so degenerate strings
like ".", "1.", or "..." fall back to tagName instead of being accepted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the feat/multiple-firmware-sources branch from c3062a9 to 12f1ba8 Compare March 11, 2026 16:34
@torlando-tech torlando-tech merged commit ac9d9bc into main Mar 11, 2026
14 checks passed
@torlando-tech torlando-tech deleted the feat/multiple-firmware-sources branch March 11, 2026 17:21
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.

After 0.9.0 release: Add microReticulum, Community Edition and custom RNode firmware flash capabilities

1 participant